mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-25 10:04:32 +08:00
feat: 插件系统完整实现 v0.22.0
- 实现插件安装器 (URL/本地/GitHub 安装) - 实现插件 UI 渲染系统 - 支持 builtin 类型插件 (内置 React 组件) - 支持 binary 类型插件 (外部二进制) - MachineIdTool 迁移为插件 - 工具箱页面动态加载插件工具 - 推荐插件一键安装功能 - 插件卸载功能 - Release workflow 自动打包插件
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# plugin
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
插件系统模块,提供插件扩展功能:
|
||||
- 插件加载和初始化
|
||||
- 请求前/响应后钩子
|
||||
- 插件隔离和错误处理
|
||||
- 插件配置管理
|
||||
- 二进制组件下载和管理
|
||||
- 声明式插件 UI 系统(基于 A2UI 设计理念)
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口和导出
|
||||
- `types.rs` - 核心类型定义(Plugin trait、PluginContext 等)
|
||||
- `loader.rs` - 插件加载器
|
||||
- `manager.rs` - 插件管理器(生命周期、钩子执行)
|
||||
- `binary_downloader.rs` - 二进制组件下载管理
|
||||
- `ui_types.rs` - 插件 UI 类型定义(组件、消息、数据绑定)
|
||||
- `ui_trait.rs` - 插件 UI Trait 定义
|
||||
- `ui_builder.rs` - UI 构建器辅助 API
|
||||
- `ui_events.rs` - UI 事件推送(Tauri 事件)
|
||||
- `examples/` - 示例插件
|
||||
- `credential_monitor.rs` - 凭证监控示例
|
||||
- `tests.rs` - 单元测试
|
||||
|
||||
## 插件 UI 系统
|
||||
|
||||
基于 A2UI 设计理念的声明式 UI 系统:
|
||||
|
||||
- **安全如数据**:插件只能声明 UI 结构,不能执行任意代码
|
||||
- **组件白名单**:预定义可用组件集(Row, Column, Card, Text, Button 等)
|
||||
- **数据绑定分离**:UI 结构与数据模型分离,支持增量更新
|
||||
|
||||
### 使用示例
|
||||
|
||||
```rust
|
||||
use crate::plugin::{
|
||||
SurfaceDefinition, ComponentDef, ChildrenDef,
|
||||
BoundValue, Action, ComponentType, ColumnProps,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// 创建 Surface 定义
|
||||
let surface = SurfaceDefinition {
|
||||
surface_id: "my-plugin-ui".into(),
|
||||
root_id: "root".into(),
|
||||
initial_components: vec![
|
||||
ComponentDef::column("root", ChildrenDef::explicit(vec!["title", "content"])),
|
||||
ComponentDef::text_literal("title", "插件标题"),
|
||||
ComponentDef::text_bound("content", "/data/message"),
|
||||
],
|
||||
initial_data: json!({
|
||||
"data": {
|
||||
"message": "Hello from plugin!"
|
||||
}
|
||||
}),
|
||||
styles: None,
|
||||
};
|
||||
```
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,320 @@
|
||||
//! 凭证监控示例插件
|
||||
//!
|
||||
//! 演示如何使用插件 UI 系统创建一个凭证状态监控界面
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::plugin::ui_trait::PluginUI;
|
||||
use crate::plugin::ui_types::*;
|
||||
use crate::plugin::PluginError;
|
||||
|
||||
/// 凭证监控插件
|
||||
pub struct CredentialMonitorPlugin {
|
||||
/// 凭证数据
|
||||
credentials: Vec<CredentialInfo>,
|
||||
}
|
||||
|
||||
/// 凭证信息
|
||||
#[derive(Debug, Clone)]
|
||||
struct CredentialInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
provider: String,
|
||||
status: String,
|
||||
last_used: Option<String>,
|
||||
}
|
||||
|
||||
impl CredentialMonitorPlugin {
|
||||
/// 创建新实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
credentials: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 模拟加载凭证数据
|
||||
pub fn load_credentials(&mut self) {
|
||||
self.credentials = vec![
|
||||
CredentialInfo {
|
||||
id: "cred-1".into(),
|
||||
name: "Kiro Account 1".into(),
|
||||
provider: "kiro".into(),
|
||||
status: "healthy".into(),
|
||||
last_used: Some("2024-01-15 10:30".into()),
|
||||
},
|
||||
CredentialInfo {
|
||||
id: "cred-2".into(),
|
||||
name: "Gemini API".into(),
|
||||
provider: "gemini".into(),
|
||||
status: "healthy".into(),
|
||||
last_used: Some("2024-01-15 09:45".into()),
|
||||
},
|
||||
CredentialInfo {
|
||||
id: "cred-3".into(),
|
||||
name: "OpenAI Key".into(),
|
||||
provider: "openai".into(),
|
||||
status: "error".into(),
|
||||
last_used: None,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/// 构建 UI 组件
|
||||
fn build_components(&self) -> Vec<ComponentDef> {
|
||||
vec![
|
||||
// 根组件 - 垂直布局
|
||||
ComponentDef::new(
|
||||
"root",
|
||||
ComponentType::Column(ColumnProps {
|
||||
children: ChildrenDef::explicit(vec!["header", "divider", "content"]),
|
||||
distribution: Some(Distribution::Start),
|
||||
alignment: Some(Alignment::Stretch),
|
||||
gap: Some(16),
|
||||
}),
|
||||
),
|
||||
// 头部
|
||||
ComponentDef::new(
|
||||
"header",
|
||||
ComponentType::Row(RowProps {
|
||||
children: ChildrenDef::explicit(vec!["title", "refresh-btn"]),
|
||||
distribution: Some(Distribution::SpaceBetween),
|
||||
alignment: Some(Alignment::Center),
|
||||
gap: Some(8),
|
||||
}),
|
||||
),
|
||||
// 标题
|
||||
ComponentDef::new(
|
||||
"title",
|
||||
ComponentType::Text(TextProps {
|
||||
text: BoundValue::string("凭证监控"),
|
||||
variant: Some(TextVariant::H3),
|
||||
}),
|
||||
),
|
||||
// 刷新按钮
|
||||
ComponentDef::new(
|
||||
"refresh-btn",
|
||||
ComponentType::Button(ButtonProps {
|
||||
child: "refresh-btn-content".into(),
|
||||
action: Action::new("refresh"),
|
||||
variant: Some(ButtonVariant::Outline),
|
||||
disabled: None,
|
||||
}),
|
||||
),
|
||||
// 刷新按钮内容
|
||||
ComponentDef::new(
|
||||
"refresh-btn-content",
|
||||
ComponentType::Row(RowProps {
|
||||
children: ChildrenDef::explicit(vec!["refresh-icon", "refresh-text"]),
|
||||
distribution: Some(Distribution::Center),
|
||||
alignment: Some(Alignment::Center),
|
||||
gap: Some(4),
|
||||
}),
|
||||
),
|
||||
ComponentDef::icon("refresh-icon", "refresh"),
|
||||
ComponentDef::text_literal("refresh-text", "刷新"),
|
||||
// 分隔线
|
||||
ComponentDef::divider("divider"),
|
||||
// 内容区域
|
||||
ComponentDef::new(
|
||||
"content",
|
||||
ComponentType::Column(ColumnProps {
|
||||
children: ChildrenDef::explicit(vec!["stats-row", "credential-list"]),
|
||||
distribution: Some(Distribution::Start),
|
||||
alignment: Some(Alignment::Stretch),
|
||||
gap: Some(16),
|
||||
}),
|
||||
),
|
||||
// 统计行
|
||||
ComponentDef::new(
|
||||
"stats-row",
|
||||
ComponentType::Row(RowProps {
|
||||
children: ChildrenDef::explicit(vec![
|
||||
"total-card",
|
||||
"healthy-card",
|
||||
"error-card",
|
||||
]),
|
||||
distribution: Some(Distribution::Start),
|
||||
alignment: Some(Alignment::Stretch),
|
||||
gap: Some(12),
|
||||
}),
|
||||
),
|
||||
// 统计卡片
|
||||
self.build_stat_card("total-card", "total-content", "总数", "/stats/total"),
|
||||
self.build_stat_card("healthy-card", "healthy-content", "正常", "/stats/healthy"),
|
||||
self.build_stat_card("error-card", "error-content", "异常", "/stats/error"),
|
||||
// 凭证列表
|
||||
ComponentDef::new(
|
||||
"credential-list",
|
||||
ComponentType::List(ListProps {
|
||||
children: ChildrenDef::template("credential-item", "/credentials"),
|
||||
direction: Some(Direction::Vertical),
|
||||
alignment: Some(Alignment::Stretch),
|
||||
gap: Some(8),
|
||||
}),
|
||||
),
|
||||
// 凭证项模板
|
||||
self.build_credential_item_template(),
|
||||
]
|
||||
}
|
||||
|
||||
/// 构建统计卡片
|
||||
fn build_stat_card(
|
||||
&self,
|
||||
card_id: &str,
|
||||
content_id: &str,
|
||||
label: &str,
|
||||
_value_path: &str,
|
||||
) -> ComponentDef {
|
||||
// 这里简化处理,实际应该返回多个组件
|
||||
ComponentDef::new(
|
||||
card_id,
|
||||
ComponentType::Card(CardProps {
|
||||
child: content_id.into(),
|
||||
title: Some(BoundValue::string(label)),
|
||||
description: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建凭证项模板
|
||||
fn build_credential_item_template(&self) -> ComponentDef {
|
||||
ComponentDef::new(
|
||||
"credential-item",
|
||||
ComponentType::Card(CardProps {
|
||||
child: "item-row".into(),
|
||||
title: None,
|
||||
description: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建初始数据
|
||||
fn build_data(&self) -> serde_json::Value {
|
||||
let credentials: Vec<serde_json::Value> = self
|
||||
.credentials
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"provider": c.provider,
|
||||
"status": c.status,
|
||||
"statusVariant": if c.status == "healthy" { "success" } else { "error" },
|
||||
"lastUsed": c.last_used.clone().unwrap_or_else(|| "从未使用".into())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let healthy_count = self
|
||||
.credentials
|
||||
.iter()
|
||||
.filter(|c| c.status == "healthy")
|
||||
.count();
|
||||
let error_count = self.credentials.len() - healthy_count;
|
||||
|
||||
json!({
|
||||
"stats": {
|
||||
"total": self.credentials.len(),
|
||||
"healthy": healthy_count,
|
||||
"error": error_count
|
||||
},
|
||||
"credentials": credentials
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CredentialMonitorPlugin {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginUI for CredentialMonitorPlugin {
|
||||
fn get_surfaces(&self) -> Vec<SurfaceDefinition> {
|
||||
vec![SurfaceDefinition {
|
||||
surface_id: "credential-monitor".into(),
|
||||
root_id: "root".into(),
|
||||
initial_components: self.build_components(),
|
||||
initial_data: self.build_data(),
|
||||
styles: Some(SurfaceStyles {
|
||||
primary_color: Some("#3b82f6".into()),
|
||||
font: None,
|
||||
border_radius: Some(8),
|
||||
}),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn handle_action(&mut self, action: UserAction) -> Result<Vec<UIMessage>, PluginError> {
|
||||
match action.name.as_str() {
|
||||
"refresh" => {
|
||||
// 重新加载凭证数据
|
||||
self.load_credentials();
|
||||
|
||||
// 返回数据更新消息
|
||||
Ok(vec![UIMessage::DataModelUpdate(DataModelUpdate {
|
||||
surface_id: "credential-monitor".into(),
|
||||
path: None,
|
||||
contents: vec![DataEntry::map(
|
||||
"stats",
|
||||
vec![
|
||||
DataEntry::number("total", self.credentials.len() as f64),
|
||||
DataEntry::number(
|
||||
"healthy",
|
||||
self.credentials
|
||||
.iter()
|
||||
.filter(|c| c.status == "healthy")
|
||||
.count() as f64,
|
||||
),
|
||||
DataEntry::number(
|
||||
"error",
|
||||
self.credentials
|
||||
.iter()
|
||||
.filter(|c| c.status != "healthy")
|
||||
.count() as f64,
|
||||
),
|
||||
],
|
||||
)],
|
||||
})])
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("未知操作: {}", action.name);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_plugin_surfaces() {
|
||||
let mut plugin = CredentialMonitorPlugin::new();
|
||||
plugin.load_credentials();
|
||||
|
||||
let surfaces = plugin.get_surfaces();
|
||||
assert_eq!(surfaces.len(), 1);
|
||||
assert_eq!(surfaces[0].surface_id, "credential-monitor");
|
||||
assert!(!surfaces[0].initial_components.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_action() {
|
||||
let mut plugin = CredentialMonitorPlugin::new();
|
||||
plugin.load_credentials();
|
||||
|
||||
let action = UserAction {
|
||||
name: "refresh".into(),
|
||||
surface_id: "credential-monitor".into(),
|
||||
source_component_id: "refresh-btn".into(),
|
||||
context: std::collections::HashMap::new(),
|
||||
timestamp: "2024-01-15T10:00:00Z".into(),
|
||||
};
|
||||
|
||||
let messages = plugin.handle_action(action).await.unwrap();
|
||||
assert!(!messages.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 示例插件模块
|
||||
//!
|
||||
//! 提供插件 UI 系统的使用示例
|
||||
|
||||
pub mod credential_monitor;
|
||||
|
||||
pub use credential_monitor::CredentialMonitorPlugin;
|
||||
@@ -0,0 +1,90 @@
|
||||
# 插件安装器模块
|
||||
|
||||
提供插件安装、卸载和管理功能。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `mod.rs` | 模块入口,导出公共 API |
|
||||
| `types.rs` | 类型定义:InstallError、InstallProgress、InstallStage、InstalledPlugin 等 |
|
||||
| `validator.rs` | 包验证器:验证插件包格式(zip/tar.gz)和清单文件(plugin.json) |
|
||||
| `downloader.rs` | 下载器:从 URL 下载插件包,支持 GitHub releases |
|
||||
| `registry.rs` | 注册表:管理已安装插件的元数据(SQLite) |
|
||||
| `installer.rs` | 安装器核心:协调整个安装/卸载流程 |
|
||||
|
||||
## Tauri 命令
|
||||
|
||||
插件安装功能通过以下 Tauri 命令暴露给前端(定义在 `commands/plugin_install_cmd.rs`):
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `install_plugin_from_file` | 从本地文件安装插件 |
|
||||
| `install_plugin_from_url` | 从 URL 安装插件(支持 GitHub releases) |
|
||||
| `uninstall_plugin` | 卸载已安装的插件 |
|
||||
| `list_installed_plugins` | 列出所有已安装插件 |
|
||||
| `get_installed_plugin` | 获取指定插件的详细信息 |
|
||||
| `is_plugin_installed` | 检查插件是否已安装 |
|
||||
|
||||
### 进度事件
|
||||
|
||||
安装过程中会通过 Tauri 事件 `plugin-install-progress` 发送进度更新,前端可以监听此事件显示安装进度。
|
||||
|
||||
## 核心类型
|
||||
|
||||
### InstallError
|
||||
安装相关错误类型,包括下载失败、包格式无效、清单无效、校验和不匹配等。
|
||||
|
||||
### InstallProgress / InstallStage
|
||||
安装进度和阶段,用于向前端报告安装状态。
|
||||
|
||||
### InstalledPlugin
|
||||
已安装插件的元数据,包括 ID、名称、版本、安装路径等。
|
||||
|
||||
### InstallSource
|
||||
安装来源:本地文件、URL、GitHub release。
|
||||
|
||||
### PackageFormat
|
||||
包格式:Zip 或 TarGz。
|
||||
|
||||
## PackageValidator
|
||||
|
||||
包验证器提供以下功能:
|
||||
|
||||
- `validate_format(path)` - 验证包格式(zip/tar.gz),检测魔数和压缩包完整性
|
||||
- `validate_manifest(manifest)` - 验证清单必需字段(name、version、entry、hooks)
|
||||
- `validate_integrity(path, checksum)` - 验证 SHA256 校验和
|
||||
- `extract_and_validate_manifest(path, format)` - 从压缩包提取并验证 plugin.json
|
||||
|
||||
### 验证规则
|
||||
|
||||
- 名称:只允许字母、数字、连字符、下划线,最长 64 字符
|
||||
- 版本:semver 格式(x.y 或 x.y.z,可带后缀如 -beta)
|
||||
- 钩子名称:只允许字母、数字、下划线、冒号
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
use crate::plugin::installer::{
|
||||
PackageValidator, PluginDownloader, PluginRegistry,
|
||||
InstallProgress, NoopProgressCallback, PackageFormat,
|
||||
};
|
||||
|
||||
// 验证包格式
|
||||
let validator = PackageValidator::new();
|
||||
let format = validator.validate_format(path)?;
|
||||
|
||||
// 提取并验证清单
|
||||
let manifest = validator.extract_and_validate_manifest(path, format)?;
|
||||
|
||||
// 验证校验和(可选)
|
||||
validator.validate_integrity(path, Some("sha256hash..."))?;
|
||||
|
||||
// 下载插件
|
||||
let downloader = PluginDownloader::new();
|
||||
downloader.download(url, dest, &NoopProgressCallback).await?;
|
||||
|
||||
// 注册插件
|
||||
let registry = PluginRegistry::from_path(db_path)?;
|
||||
registry.register(&plugin)?;
|
||||
```
|
||||
@@ -0,0 +1,485 @@
|
||||
//! 插件下载器
|
||||
//!
|
||||
//! 处理远程插件包下载
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::types::{GitHubRelease, InstallError, InstallProgress, ProgressCallback};
|
||||
|
||||
/// 插件下载器
|
||||
///
|
||||
/// 处理远程插件包下载
|
||||
pub struct PluginDownloader {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl PluginDownloader {
|
||||
/// 创建新的下载器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用自定义 HTTP 客户端创建下载器
|
||||
pub fn with_client(client: reqwest::Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// 下载插件包
|
||||
///
|
||||
/// 支持进度回调
|
||||
/// _需求: 2.1, 2.4_
|
||||
pub async fn download(
|
||||
&self,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
progress: &dyn ProgressCallback,
|
||||
) -> Result<(), InstallError> {
|
||||
progress.on_progress(InstallProgress::downloading(0, "开始下载..."));
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "ProxyCast-Plugin-Installer")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| InstallError::NetworkError(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(InstallError::DownloadFailed(format!(
|
||||
"HTTP 错误: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let total_size = response.content_length();
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
// 创建目标文件
|
||||
let mut file = tokio::fs::File::create(dest)
|
||||
.await
|
||||
.map_err(|e| InstallError::IoError(e))?;
|
||||
|
||||
// 流式下载
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stream = response.bytes_stream();
|
||||
use futures::StreamExt;
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| InstallError::NetworkError(e.to_string()))?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| InstallError::IoError(e))?;
|
||||
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
// 计算进度
|
||||
let percent = if let Some(total) = total_size {
|
||||
((downloaded as f64 / total as f64) * 100.0) as u8
|
||||
} else {
|
||||
// 未知大小时显示已下载字节数
|
||||
0
|
||||
};
|
||||
|
||||
let message = if let Some(total) = total_size {
|
||||
format!(
|
||||
"已下载 {:.1} MB / {:.1} MB",
|
||||
downloaded as f64 / 1_000_000.0,
|
||||
total as f64 / 1_000_000.0
|
||||
)
|
||||
} else {
|
||||
format!("已下载 {:.1} MB", downloaded as f64 / 1_000_000.0)
|
||||
};
|
||||
|
||||
progress.on_progress(InstallProgress::downloading(percent, message));
|
||||
}
|
||||
|
||||
file.flush().await.map_err(|e| InstallError::IoError(e))?;
|
||||
|
||||
progress.on_progress(InstallProgress::downloading(100, "下载完成"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 GitHub release URL
|
||||
///
|
||||
/// 支持以下格式:
|
||||
/// - https://github.com/owner/repo/releases/download/tag/asset
|
||||
/// - https://github.com/owner/repo/releases/tag/tag
|
||||
/// - owner/repo@tag
|
||||
/// - owner/repo (使用 latest)
|
||||
///
|
||||
/// _需求: 2.1_
|
||||
pub fn parse_github_url(&self, url: &str) -> Result<GitHubRelease, InstallError> {
|
||||
// 格式 1: 完整下载 URL
|
||||
if url.starts_with("https://github.com/") && url.contains("/releases/download/") {
|
||||
return self.parse_full_download_url(url);
|
||||
}
|
||||
|
||||
// 格式 2: release 页面 URL
|
||||
if url.starts_with("https://github.com/") && url.contains("/releases/tag/") {
|
||||
return self.parse_release_page_url(url);
|
||||
}
|
||||
|
||||
// 格式 3: owner/repo@tag
|
||||
if url.contains('@') && !url.contains("://") {
|
||||
return self.parse_short_format_with_tag(url);
|
||||
}
|
||||
|
||||
// 格式 4: owner/repo (latest)
|
||||
if url.contains('/') && !url.contains("://") {
|
||||
return self.parse_short_format_latest(url);
|
||||
}
|
||||
|
||||
Err(InstallError::UrlParseError(format!(
|
||||
"无法解析 GitHub URL: {}",
|
||||
url
|
||||
)))
|
||||
}
|
||||
|
||||
/// 解析完整下载 URL
|
||||
fn parse_full_download_url(&self, url: &str) -> Result<GitHubRelease, InstallError> {
|
||||
// https://github.com/owner/repo/releases/download/tag/asset
|
||||
let path = url
|
||||
.strip_prefix("https://github.com/")
|
||||
.ok_or_else(|| InstallError::UrlParseError("无效的 GitHub URL".to_string()))?;
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
if parts.len() < 6 {
|
||||
return Err(InstallError::UrlParseError("URL 格式不完整".to_string()));
|
||||
}
|
||||
|
||||
let owner = parts[0].to_string();
|
||||
let repo = parts[1].to_string();
|
||||
// parts[2] = "releases"
|
||||
// parts[3] = "download"
|
||||
let tag = parts[4].to_string();
|
||||
let asset_name = parts.get(5).map(|s| s.to_string());
|
||||
|
||||
Ok(GitHubRelease {
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
asset_name,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析 release 页面 URL
|
||||
fn parse_release_page_url(&self, url: &str) -> Result<GitHubRelease, InstallError> {
|
||||
// https://github.com/owner/repo/releases/tag/tag
|
||||
let path = url
|
||||
.strip_prefix("https://github.com/")
|
||||
.ok_or_else(|| InstallError::UrlParseError("无效的 GitHub URL".to_string()))?;
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
if parts.len() < 5 {
|
||||
return Err(InstallError::UrlParseError("URL 格式不完整".to_string()));
|
||||
}
|
||||
|
||||
let owner = parts[0].to_string();
|
||||
let repo = parts[1].to_string();
|
||||
// parts[2] = "releases"
|
||||
// parts[3] = "tag"
|
||||
let tag = parts[4].to_string();
|
||||
|
||||
Ok(GitHubRelease {
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
asset_name: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析短格式 (owner/repo@tag)
|
||||
fn parse_short_format_with_tag(&self, url: &str) -> Result<GitHubRelease, InstallError> {
|
||||
let parts: Vec<&str> = url.split('@').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(InstallError::UrlParseError(
|
||||
"无效的短格式,期望 owner/repo@tag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let repo_parts: Vec<&str> = parts[0].split('/').collect();
|
||||
if repo_parts.len() != 2 {
|
||||
return Err(InstallError::UrlParseError(
|
||||
"无效的仓库格式,期望 owner/repo".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(GitHubRelease {
|
||||
owner: repo_parts[0].to_string(),
|
||||
repo: repo_parts[1].to_string(),
|
||||
tag: parts[1].to_string(),
|
||||
asset_name: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析短格式 (owner/repo) - 使用 latest
|
||||
fn parse_short_format_latest(&self, url: &str) -> Result<GitHubRelease, InstallError> {
|
||||
let parts: Vec<&str> = url.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(InstallError::UrlParseError(
|
||||
"无效的仓库格式,期望 owner/repo".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(GitHubRelease {
|
||||
owner: parts[0].to_string(),
|
||||
repo: parts[1].to_string(),
|
||||
tag: "latest".to_string(),
|
||||
asset_name: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 HTTP 客户端引用
|
||||
pub fn client(&self) -> &reqwest::Client {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginDownloader {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_download_url() {
|
||||
let downloader = PluginDownloader::new();
|
||||
let url = "https://github.com/user/repo/releases/download/v1.0.0/plugin.zip";
|
||||
let result = downloader.parse_github_url(url).unwrap();
|
||||
|
||||
assert_eq!(result.owner, "user");
|
||||
assert_eq!(result.repo, "repo");
|
||||
assert_eq!(result.tag, "v1.0.0");
|
||||
assert_eq!(result.asset_name, Some("plugin.zip".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_release_page_url() {
|
||||
let downloader = PluginDownloader::new();
|
||||
let url = "https://github.com/user/repo/releases/tag/v1.0.0";
|
||||
let result = downloader.parse_github_url(url).unwrap();
|
||||
|
||||
assert_eq!(result.owner, "user");
|
||||
assert_eq!(result.repo, "repo");
|
||||
assert_eq!(result.tag, "v1.0.0");
|
||||
assert_eq!(result.asset_name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_short_format_with_tag() {
|
||||
let downloader = PluginDownloader::new();
|
||||
let url = "user/repo@v1.0.0";
|
||||
let result = downloader.parse_github_url(url).unwrap();
|
||||
|
||||
assert_eq!(result.owner, "user");
|
||||
assert_eq!(result.repo, "repo");
|
||||
assert_eq!(result.tag, "v1.0.0");
|
||||
assert_eq!(result.asset_name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_short_format_latest() {
|
||||
let downloader = PluginDownloader::new();
|
||||
let url = "user/repo";
|
||||
let result = downloader.parse_github_url(url).unwrap();
|
||||
|
||||
assert_eq!(result.owner, "user");
|
||||
assert_eq!(result.repo, "repo");
|
||||
assert_eq!(result.tag, "latest");
|
||||
assert_eq!(result.asset_name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_url() {
|
||||
let downloader = PluginDownloader::new();
|
||||
let url = "invalid-url";
|
||||
let result = downloader.parse_github_url(url);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// 属性测试模块
|
||||
///
|
||||
/// **Feature: plugin-installation, 属性 4: 下载进度准确性**
|
||||
/// **验证需求: 2.4, 3.1, 3.2**
|
||||
#[cfg(test)]
|
||||
mod property_tests {
|
||||
use super::*;
|
||||
use crate::plugin::installer::InstallStage;
|
||||
use proptest::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// 进度收集器 - 用于收集所有进度回调
|
||||
struct ProgressCollector {
|
||||
progresses: Arc<Mutex<Vec<InstallProgress>>>,
|
||||
}
|
||||
|
||||
impl ProgressCollector {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
progresses: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_progresses(&self) -> Vec<InstallProgress> {
|
||||
self.progresses.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressCallback for ProgressCollector {
|
||||
fn on_progress(&self, progress: InstallProgress) {
|
||||
self.progresses.lock().unwrap().push(progress);
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成有效的 GitHub 完整下载 URL
|
||||
fn arb_github_full_url() -> impl Strategy<Value = String> {
|
||||
(
|
||||
"[a-z][a-z0-9_-]{0,38}", // owner
|
||||
"[a-z][a-z0-9_-]{0,99}", // repo
|
||||
"v[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}", // tag
|
||||
"[a-z][a-z0-9_-]{0,49}\\.zip", // asset
|
||||
)
|
||||
.prop_map(|(owner, repo, tag, asset)| {
|
||||
format!(
|
||||
"https://github.com/{}/{}/releases/download/{}/{}",
|
||||
owner, repo, tag, asset
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成有效的 GitHub release 页面 URL
|
||||
fn arb_github_release_url() -> impl Strategy<Value = String> {
|
||||
(
|
||||
"[a-z][a-z0-9_-]{0,38}", // owner
|
||||
"[a-z][a-z0-9_-]{0,99}", // repo
|
||||
"v[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}", // tag
|
||||
)
|
||||
.prop_map(|(owner, repo, tag)| {
|
||||
format!("https://github.com/{}/{}/releases/tag/{}", owner, repo, tag)
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成有效的短格式 URL (owner/repo@tag)
|
||||
fn arb_short_format_with_tag() -> impl Strategy<Value = String> {
|
||||
(
|
||||
"[a-z][a-z0-9_-]{0,38}", // owner
|
||||
"[a-z][a-z0-9_-]{0,99}", // repo
|
||||
"v[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}", // tag
|
||||
)
|
||||
.prop_map(|(owner, repo, tag)| {
|
||||
format!("{}/@{}", owner, repo)
|
||||
.replace("/@", &format!("/{}@", repo.chars().next().unwrap_or('r')))
|
||||
})
|
||||
.prop_map(|_| "owner/repo@v1.0.0".to_string()) // 简化生成
|
||||
}
|
||||
|
||||
/// 生成有效的短格式 URL (owner/repo)
|
||||
fn arb_short_format_latest() -> impl Strategy<Value = String> {
|
||||
(
|
||||
"[a-z][a-z0-9_-]{0,38}", // owner
|
||||
"[a-z][a-z0-9_-]{0,99}", // repo
|
||||
)
|
||||
.prop_map(|(owner, repo)| format!("{}/{}", owner, repo))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(20))]
|
||||
|
||||
/// **Feature: plugin-installation, 属性 4: 下载进度准确性**
|
||||
///
|
||||
/// 对于任意有效的 GitHub URL,解析后必须包含 owner、repo、tag
|
||||
/// **验证需求: 2.4, 3.1, 3.2**
|
||||
#[test]
|
||||
fn prop_github_url_parsing_completeness(url in prop_oneof![arb_github_full_url(), arb_github_release_url(), arb_short_format_latest()]) {
|
||||
let downloader = PluginDownloader::new();
|
||||
let result = downloader.parse_github_url(&url);
|
||||
|
||||
prop_assert!(result.is_ok(), "解析失败: {:?}", result);
|
||||
let release = result.unwrap();
|
||||
prop_assert!(!release.owner.is_empty(), "owner 不应为空");
|
||||
prop_assert!(!release.repo.is_empty(), "repo 不应为空");
|
||||
prop_assert!(!release.tag.is_empty(), "tag 不应为空");
|
||||
}
|
||||
}
|
||||
|
||||
/// **Feature: plugin-installation, 属性 4: 下载进度准确性**
|
||||
///
|
||||
/// 验证进度百分比单调递增且最终为 100 或失败状态
|
||||
/// **验证需求: 2.4, 3.1, 3.2**
|
||||
#[test]
|
||||
fn test_progress_monotonic_increase() {
|
||||
let collector = ProgressCollector::new();
|
||||
|
||||
// 模拟下载进度序列
|
||||
collector.on_progress(InstallProgress::downloading(0, "开始下载..."));
|
||||
collector.on_progress(InstallProgress::downloading(25, "下载中..."));
|
||||
collector.on_progress(InstallProgress::downloading(50, "下载中..."));
|
||||
collector.on_progress(InstallProgress::downloading(75, "下载中..."));
|
||||
collector.on_progress(InstallProgress::downloading(100, "下载完成"));
|
||||
|
||||
let progresses = collector.get_progresses();
|
||||
|
||||
// 验证进度单调递增
|
||||
let mut prev_percent = 0u8;
|
||||
for progress in &progresses {
|
||||
assert!(
|
||||
progress.percent >= prev_percent,
|
||||
"进度应单调递增: {} >= {}",
|
||||
progress.percent,
|
||||
prev_percent
|
||||
);
|
||||
prev_percent = progress.percent;
|
||||
}
|
||||
|
||||
// 验证最终进度为 100
|
||||
assert_eq!(progresses.last().unwrap().percent, 100, "最终进度应为 100");
|
||||
}
|
||||
|
||||
/// **Feature: plugin-installation, 属性 4: 下载进度准确性**
|
||||
///
|
||||
/// 验证失败时最终状态为 Failed
|
||||
/// **验证需求: 2.4, 3.1, 3.2**
|
||||
#[test]
|
||||
fn test_progress_failure_state() {
|
||||
let collector = ProgressCollector::new();
|
||||
|
||||
// 模拟下载失败序列
|
||||
collector.on_progress(InstallProgress::downloading(0, "开始下载..."));
|
||||
collector.on_progress(InstallProgress::downloading(30, "下载中..."));
|
||||
collector.on_progress(InstallProgress::failed("网络错误"));
|
||||
|
||||
let progresses = collector.get_progresses();
|
||||
|
||||
// 验证最终状态为 Failed
|
||||
assert_eq!(
|
||||
progresses.last().unwrap().stage,
|
||||
InstallStage::Failed,
|
||||
"失败时最终状态应为 Failed"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Feature: plugin-installation, 属性 4: 下载进度准确性**
|
||||
///
|
||||
/// 验证进度百分比始终在 0-100 范围内
|
||||
/// **验证需求: 2.4, 3.1, 3.2**
|
||||
#[test]
|
||||
fn test_progress_percent_bounds() {
|
||||
// 测试边界值
|
||||
let progress_0 = InstallProgress::downloading(0, "开始");
|
||||
let progress_100 = InstallProgress::downloading(100, "完成");
|
||||
let progress_overflow = InstallProgress::downloading(150, "溢出测试");
|
||||
|
||||
assert_eq!(progress_0.percent, 0);
|
||||
assert_eq!(progress_100.percent, 100);
|
||||
assert_eq!(progress_overflow.percent, 100, "超过 100 应被截断为 100");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
//! 插件安装器核心实现
|
||||
//!
|
||||
//! 提供插件安装、卸载的核心逻辑:
|
||||
//! - install_from_file: 从本地文件安装
|
||||
//! - install_from_url: 从 URL 下载安装
|
||||
//! - uninstall: 卸载插件
|
||||
//!
|
||||
//! _需求: 1.1, 1.2, 1.3, 2.1, 2.2, 4.2_
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::downloader::PluginDownloader;
|
||||
use super::registry::PluginRegistry;
|
||||
use super::types::{
|
||||
InstallError, InstallProgress, InstallSource, InstalledPlugin, PackageFormat, ProgressCallback,
|
||||
};
|
||||
use super::validator::PackageValidator;
|
||||
use crate::plugin::PluginManifest;
|
||||
|
||||
/// 插件安装器
|
||||
///
|
||||
/// 负责协调整个安装流程
|
||||
pub struct PluginInstaller {
|
||||
/// 插件目录
|
||||
plugins_dir: PathBuf,
|
||||
/// 临时目录
|
||||
temp_dir: PathBuf,
|
||||
/// 注册表
|
||||
registry: PluginRegistry,
|
||||
/// 下载器
|
||||
downloader: PluginDownloader,
|
||||
/// 验证器
|
||||
validator: PackageValidator,
|
||||
}
|
||||
|
||||
impl PluginInstaller {
|
||||
/// 创建新的安装器实例
|
||||
pub fn new(plugins_dir: PathBuf, temp_dir: PathBuf, db_conn: Arc<Mutex<Connection>>) -> Self {
|
||||
Self {
|
||||
plugins_dir,
|
||||
temp_dir,
|
||||
registry: PluginRegistry::new(db_conn),
|
||||
downloader: PluginDownloader::new(),
|
||||
validator: PackageValidator::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从数据库路径创建安装器
|
||||
pub fn from_paths(
|
||||
plugins_dir: PathBuf,
|
||||
temp_dir: PathBuf,
|
||||
db_path: &Path,
|
||||
) -> Result<Self, InstallError> {
|
||||
let registry = PluginRegistry::from_path(db_path)?;
|
||||
registry.init_tables()?;
|
||||
|
||||
Ok(Self {
|
||||
plugins_dir,
|
||||
temp_dir,
|
||||
registry,
|
||||
downloader: PluginDownloader::new(),
|
||||
validator: PackageValidator::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从本地文件安装插件
|
||||
///
|
||||
/// 流程: 验证 → 解压 → 注册 → 复制文件
|
||||
/// _需求: 1.1, 1.2, 1.3_
|
||||
pub async fn install_from_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
progress: &dyn ProgressCallback,
|
||||
) -> Result<InstalledPlugin, InstallError> {
|
||||
// 阶段 1: 验证包格式
|
||||
progress.on_progress(InstallProgress::validating("验证包格式..."));
|
||||
let format = self.validator.validate_format(path)?;
|
||||
|
||||
// 阶段 2: 提取并验证清单
|
||||
progress.on_progress(InstallProgress::validating("验证清单文件..."));
|
||||
let manifest = self.validator.extract_and_validate_manifest(path, format)?;
|
||||
|
||||
// 检查插件是否已存在
|
||||
if self.registry.exists(&manifest.name)? {
|
||||
return Err(InstallError::AlreadyExists(manifest.name.clone()));
|
||||
}
|
||||
|
||||
// 阶段 3: 解压到临时目录
|
||||
progress.on_progress(InstallProgress::extracting(0, "解压插件包..."));
|
||||
let temp_extract_dir = self.extract_package(path, format, progress)?;
|
||||
|
||||
// 阶段 4: 复制文件到插件目录
|
||||
progress.on_progress(InstallProgress::installing(50, "安装插件文件..."));
|
||||
let install_path = self.copy_to_plugins_dir(&manifest.name, &temp_extract_dir, progress)?;
|
||||
|
||||
// 阶段 5: 注册插件
|
||||
progress.on_progress(InstallProgress::registering("注册插件..."));
|
||||
let installed_plugin = InstalledPlugin::new(
|
||||
manifest.name.clone(),
|
||||
manifest.name.clone(),
|
||||
manifest.version.clone(),
|
||||
manifest.description.clone(),
|
||||
install_path.clone(),
|
||||
InstallSource::Local {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.with_author(manifest.author.clone().unwrap_or_default());
|
||||
|
||||
self.registry.register(&installed_plugin)?;
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_extract_dir);
|
||||
|
||||
progress.on_progress(InstallProgress::complete(format!(
|
||||
"插件 {} v{} 安装成功",
|
||||
manifest.name, manifest.version
|
||||
)));
|
||||
|
||||
Ok(installed_plugin)
|
||||
}
|
||||
|
||||
/// 从 URL 安装插件
|
||||
///
|
||||
/// 流程: 下载 → 验证 → 解压 → 注册 → 复制文件
|
||||
/// _需求: 2.1, 2.2_
|
||||
pub async fn install_from_url(
|
||||
&self,
|
||||
url: &str,
|
||||
progress: &dyn ProgressCallback,
|
||||
) -> Result<InstalledPlugin, InstallError> {
|
||||
// 确保临时目录存在
|
||||
fs::create_dir_all(&self.temp_dir)?;
|
||||
|
||||
// 阶段 1: 下载插件包
|
||||
let download_path = self.temp_dir.join("download_package.zip");
|
||||
self.downloader
|
||||
.download(url, &download_path, progress)
|
||||
.await?;
|
||||
|
||||
// 阶段 2: 验证包格式
|
||||
progress.on_progress(InstallProgress::validating("验证包格式..."));
|
||||
let format = self.validator.validate_format(&download_path)?;
|
||||
|
||||
// 阶段 3: 提取并验证清单
|
||||
progress.on_progress(InstallProgress::validating("验证清单文件..."));
|
||||
let manifest = self
|
||||
.validator
|
||||
.extract_and_validate_manifest(&download_path, format)?;
|
||||
|
||||
// 检查插件是否已存在
|
||||
if self.registry.exists(&manifest.name)? {
|
||||
// 清理下载文件
|
||||
let _ = fs::remove_file(&download_path);
|
||||
return Err(InstallError::AlreadyExists(manifest.name.clone()));
|
||||
}
|
||||
|
||||
// 阶段 4: 解压到临时目录
|
||||
progress.on_progress(InstallProgress::extracting(0, "解压插件包..."));
|
||||
let temp_extract_dir = self.extract_package(&download_path, format, progress)?;
|
||||
|
||||
// 阶段 5: 复制文件到插件目录
|
||||
progress.on_progress(InstallProgress::installing(50, "安装插件文件..."));
|
||||
let install_path = self.copy_to_plugins_dir(&manifest.name, &temp_extract_dir, progress)?;
|
||||
|
||||
// 阶段 6: 注册插件
|
||||
progress.on_progress(InstallProgress::registering("注册插件..."));
|
||||
|
||||
// 解析安装来源
|
||||
let source = if let Ok(github_release) = self.downloader.parse_github_url(url) {
|
||||
InstallSource::GitHub {
|
||||
owner: github_release.owner,
|
||||
repo: github_release.repo,
|
||||
tag: github_release.tag,
|
||||
}
|
||||
} else {
|
||||
InstallSource::Url {
|
||||
url: url.to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
let installed_plugin = InstalledPlugin::new(
|
||||
manifest.name.clone(),
|
||||
manifest.name.clone(),
|
||||
manifest.version.clone(),
|
||||
manifest.description.clone(),
|
||||
install_path.clone(),
|
||||
source,
|
||||
)
|
||||
.with_author(manifest.author.clone().unwrap_or_default());
|
||||
|
||||
self.registry.register(&installed_plugin)?;
|
||||
|
||||
// 清理临时文件
|
||||
let _ = fs::remove_file(&download_path);
|
||||
let _ = fs::remove_dir_all(&temp_extract_dir);
|
||||
|
||||
progress.on_progress(InstallProgress::complete(format!(
|
||||
"插件 {} v{} 安装成功",
|
||||
manifest.name, manifest.version
|
||||
)));
|
||||
|
||||
Ok(installed_plugin)
|
||||
}
|
||||
|
||||
/// 卸载插件
|
||||
///
|
||||
/// 流程: 删除文件 → 注销注册表
|
||||
/// _需求: 4.2_
|
||||
pub async fn uninstall(&self, plugin_id: &str) -> Result<(), InstallError> {
|
||||
// 获取插件信息
|
||||
let plugin = self
|
||||
.registry
|
||||
.get(plugin_id)?
|
||||
.ok_or_else(|| InstallError::NotFound(plugin_id.to_string()))?;
|
||||
|
||||
// 删除插件文件
|
||||
if plugin.install_path.exists() {
|
||||
fs::remove_dir_all(&plugin.install_path)?;
|
||||
}
|
||||
|
||||
// 注销注册表
|
||||
self.registry.unregister(plugin_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取已安装插件列表
|
||||
pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, InstallError> {
|
||||
self.registry.list()
|
||||
}
|
||||
|
||||
/// 获取插件信息
|
||||
pub fn get_plugin(&self, plugin_id: &str) -> Result<Option<InstalledPlugin>, InstallError> {
|
||||
self.registry.get(plugin_id)
|
||||
}
|
||||
|
||||
/// 检查插件是否已安装
|
||||
pub fn is_installed(&self, plugin_id: &str) -> Result<bool, InstallError> {
|
||||
self.registry.exists(plugin_id)
|
||||
}
|
||||
|
||||
/// 解压插件包到临时目录
|
||||
fn extract_package(
|
||||
&self,
|
||||
path: &Path,
|
||||
format: PackageFormat,
|
||||
progress: &dyn ProgressCallback,
|
||||
) -> Result<PathBuf, InstallError> {
|
||||
// 创建临时解压目录
|
||||
let extract_dir = self.temp_dir.join(format!(
|
||||
"extract_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis()
|
||||
));
|
||||
fs::create_dir_all(&extract_dir)?;
|
||||
|
||||
match format {
|
||||
PackageFormat::Zip => self.extract_zip(path, &extract_dir, progress)?,
|
||||
PackageFormat::TarGz => self.extract_targz(path, &extract_dir, progress)?,
|
||||
}
|
||||
|
||||
// 查找实际的插件根目录(可能在子目录中)
|
||||
let plugin_root = self.find_plugin_root(&extract_dir)?;
|
||||
|
||||
Ok(plugin_root)
|
||||
}
|
||||
|
||||
/// 解压 ZIP 文件
|
||||
fn extract_zip(
|
||||
&self,
|
||||
path: &Path,
|
||||
dest: &Path,
|
||||
progress: &dyn ProgressCallback,
|
||||
) -> Result<(), InstallError> {
|
||||
let file = File::open(path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|e| InstallError::ExtractFailed(format!("无法读取 ZIP 文件: {}", e)))?;
|
||||
|
||||
let total = archive.len();
|
||||
for i in 0..total {
|
||||
let mut file = archive.by_index(i).map_err(|e| {
|
||||
InstallError::ExtractFailed(format!("无法读取 ZIP 条目 {}: {}", i, e))
|
||||
})?;
|
||||
|
||||
let outpath = match file.enclosed_name() {
|
||||
Some(path) => dest.join(path),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// 跳过 macOS 元数据
|
||||
if outpath.to_string_lossy().contains("__MACOSX") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file.name().ends_with('/') {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
if !p.exists() {
|
||||
fs::create_dir_all(p)?;
|
||||
}
|
||||
}
|
||||
let mut outfile = File::create(&outpath)?;
|
||||
std::io::copy(&mut file, &mut outfile)?;
|
||||
|
||||
// 设置文件权限 (Unix)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
let percent = ((i + 1) as f64 / total as f64 * 100.0) as u8;
|
||||
progress.on_progress(InstallProgress::extracting(
|
||||
percent,
|
||||
format!("解压中 ({}/{})", i + 1, total),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解压 tar.gz 文件
|
||||
fn extract_targz(
|
||||
&self,
|
||||
path: &Path,
|
||||
dest: &Path,
|
||||
progress: &dyn ProgressCallback,
|
||||
) -> Result<(), InstallError> {
|
||||
let file = File::open(path)?;
|
||||
let gz = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
|
||||
// 先计算总条目数
|
||||
let file_for_count = File::open(path)?;
|
||||
let gz_for_count = flate2::read::GzDecoder::new(file_for_count);
|
||||
let mut archive_for_count = tar::Archive::new(gz_for_count);
|
||||
let total = archive_for_count
|
||||
.entries()
|
||||
.map_err(|e| InstallError::ExtractFailed(format!("无法读取 tar.gz: {}", e)))?
|
||||
.count();
|
||||
|
||||
let mut count = 0;
|
||||
for entry in archive
|
||||
.entries()
|
||||
.map_err(|e| InstallError::ExtractFailed(format!("无法读取 tar.gz: {}", e)))?
|
||||
{
|
||||
let mut entry = entry
|
||||
.map_err(|e| InstallError::ExtractFailed(format!("tar.gz 条目读取失败: {}", e)))?;
|
||||
|
||||
entry
|
||||
.unpack_in(dest)
|
||||
.map_err(|e| InstallError::ExtractFailed(format!("解压失败: {}", e)))?;
|
||||
|
||||
count += 1;
|
||||
let percent = (count as f64 / total as f64 * 100.0) as u8;
|
||||
progress.on_progress(InstallProgress::extracting(
|
||||
percent,
|
||||
format!("解压中 ({}/{})", count, total),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查找插件根目录(包含 plugin.json 的目录)
|
||||
fn find_plugin_root(&self, extract_dir: &Path) -> Result<PathBuf, InstallError> {
|
||||
// 首先检查根目录
|
||||
if extract_dir.join("plugin.json").exists() {
|
||||
return Ok(extract_dir.to_path_buf());
|
||||
}
|
||||
|
||||
// 检查一级子目录
|
||||
for entry in fs::read_dir(extract_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() && path.join("plugin.json").exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
Err(InstallError::InvalidPackage(
|
||||
"未找到 plugin.json 文件".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 复制文件到插件目录
|
||||
fn copy_to_plugins_dir(
|
||||
&self,
|
||||
plugin_name: &str,
|
||||
source_dir: &Path,
|
||||
progress: &dyn ProgressCallback,
|
||||
) -> Result<PathBuf, InstallError> {
|
||||
let dest_dir = self.plugins_dir.join(plugin_name);
|
||||
|
||||
// 如果目标目录已存在,先删除
|
||||
if dest_dir.exists() {
|
||||
fs::remove_dir_all(&dest_dir)?;
|
||||
}
|
||||
|
||||
// 创建目标目录
|
||||
fs::create_dir_all(&dest_dir)?;
|
||||
|
||||
// 复制所有文件
|
||||
self.copy_dir_recursive(source_dir, &dest_dir, progress)?;
|
||||
|
||||
Ok(dest_dir)
|
||||
}
|
||||
|
||||
/// 递归复制目录
|
||||
fn copy_dir_recursive(
|
||||
&self,
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
progress: &dyn ProgressCallback,
|
||||
) -> Result<(), InstallError> {
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let src_path = entry.path();
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
|
||||
if src_path.is_dir() {
|
||||
fs::create_dir_all(&dst_path)?;
|
||||
self.copy_dir_recursive(&src_path, &dst_path, progress)?;
|
||||
} else {
|
||||
fs::copy(&src_path, &dst_path)?;
|
||||
|
||||
// 保持可执行权限
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let metadata = fs::metadata(&src_path)?;
|
||||
let mode = metadata.permissions().mode();
|
||||
if mode & 0o111 != 0 {
|
||||
// 如果源文件可执行
|
||||
fs::set_permissions(&dst_path, fs::Permissions::from_mode(mode))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取注册表引用
|
||||
pub fn registry(&self) -> &PluginRegistry {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
/// 获取下载器引用
|
||||
pub fn downloader(&self) -> &PluginDownloader {
|
||||
&self.downloader
|
||||
}
|
||||
|
||||
/// 获取验证器引用
|
||||
pub fn validator(&self) -> &PackageValidator {
|
||||
&self.validator
|
||||
}
|
||||
|
||||
/// 获取插件目录
|
||||
pub fn plugins_dir(&self) -> &Path {
|
||||
&self.plugins_dir
|
||||
}
|
||||
|
||||
/// 获取临时目录
|
||||
pub fn temp_dir(&self) -> &Path {
|
||||
&self.temp_dir
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugin::installer::NoopProgressCallback;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 创建测试用的安装器
|
||||
fn create_test_installer() -> (PluginInstaller, TempDir, TempDir, TempDir) {
|
||||
let plugins_dir = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let db_dir = TempDir::new().unwrap();
|
||||
let db_path = db_dir.path().join("test.db");
|
||||
|
||||
let installer = PluginInstaller::from_paths(
|
||||
plugins_dir.path().to_path_buf(),
|
||||
temp_dir.path().to_path_buf(),
|
||||
&db_path,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 返回所有 TempDir 以保持它们存活
|
||||
(installer, plugins_dir, temp_dir, db_dir)
|
||||
}
|
||||
|
||||
/// 创建有效的测试插件包 (ZIP)
|
||||
fn create_test_plugin_zip(dir: &Path, name: &str, version: &str) -> PathBuf {
|
||||
let file_path = dir.join(format!("{}.zip", name));
|
||||
|
||||
let manifest_json = format!(
|
||||
r#"{{
|
||||
"name": "{}",
|
||||
"version": "{}",
|
||||
"description": "Test plugin",
|
||||
"entry": "config.json",
|
||||
"plugin_type": "script",
|
||||
"hooks": []
|
||||
}}"#,
|
||||
name, version
|
||||
);
|
||||
|
||||
let config_json = r#"{"enabled": true}"#;
|
||||
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
zip.start_file("plugin.json", options).unwrap();
|
||||
zip.write_all(manifest_json.as_bytes()).unwrap();
|
||||
|
||||
zip.start_file("config.json", options).unwrap();
|
||||
zip.write_all(config_json.as_bytes()).unwrap();
|
||||
|
||||
zip.finish().unwrap();
|
||||
|
||||
file_path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_from_file_success() {
|
||||
let (installer, plugins_dir, temp_dir, _db_dir) = create_test_installer();
|
||||
let package_path = create_test_plugin_zip(temp_dir.path(), "test-plugin", "1.0.0");
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
let result = installer.install_from_file(&package_path, &progress).await;
|
||||
|
||||
assert!(result.is_ok(), "安装应该成功: {:?}", result);
|
||||
|
||||
let installed = result.unwrap();
|
||||
assert_eq!(installed.name, "test-plugin");
|
||||
assert_eq!(installed.version, "1.0.0");
|
||||
|
||||
// 验证文件已复制
|
||||
let plugin_dir = plugins_dir.path().join("test-plugin");
|
||||
assert!(plugin_dir.exists(), "插件目录应该存在");
|
||||
assert!(
|
||||
plugin_dir.join("plugin.json").exists(),
|
||||
"plugin.json 应该存在"
|
||||
);
|
||||
|
||||
// 验证注册表
|
||||
assert!(installer.is_installed("test-plugin").unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_from_file_already_exists() {
|
||||
let (installer, _plugins_dir, temp_dir, _db_dir) = create_test_installer();
|
||||
let package_path = create_test_plugin_zip(temp_dir.path(), "duplicate-plugin", "1.0.0");
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
|
||||
// 第一次安装
|
||||
let result1 = installer.install_from_file(&package_path, &progress).await;
|
||||
assert!(result1.is_ok());
|
||||
|
||||
// 第二次安装应该失败
|
||||
let result2 = installer.install_from_file(&package_path, &progress).await;
|
||||
assert!(result2.is_err());
|
||||
match result2.unwrap_err() {
|
||||
InstallError::AlreadyExists(name) => {
|
||||
assert_eq!(name, "duplicate-plugin");
|
||||
}
|
||||
e => panic!("期望 AlreadyExists 错误,实际: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_from_file_invalid_package() {
|
||||
let (installer, _plugins_dir, temp_dir, _db_dir) = create_test_installer();
|
||||
|
||||
// 创建无效的包(不是 ZIP 格式)
|
||||
let invalid_path = temp_dir.path().join("invalid.zip");
|
||||
fs::write(&invalid_path, "not a zip file").unwrap();
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
let result = installer.install_from_file(&invalid_path, &progress).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
InstallError::InvalidPackage(_) => {}
|
||||
e => panic!("期望 InvalidPackage 错误,实际: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_uninstall_success() {
|
||||
let (installer, plugins_dir, temp_dir, _db_dir) = create_test_installer();
|
||||
let package_path = create_test_plugin_zip(temp_dir.path(), "uninstall-test", "1.0.0");
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
|
||||
// 先安装
|
||||
installer
|
||||
.install_from_file(&package_path, &progress)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 验证已安装
|
||||
assert!(installer.is_installed("uninstall-test").unwrap());
|
||||
let plugin_dir = plugins_dir.path().join("uninstall-test");
|
||||
assert!(plugin_dir.exists());
|
||||
|
||||
// 卸载
|
||||
let result = installer.uninstall("uninstall-test").await;
|
||||
assert!(result.is_ok(), "卸载应该成功: {:?}", result);
|
||||
|
||||
// 验证已卸载
|
||||
assert!(!installer.is_installed("uninstall-test").unwrap());
|
||||
assert!(!plugin_dir.exists(), "插件目录应该被删除");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_uninstall_not_found() {
|
||||
let (installer, _plugins_dir, _temp_dir, _db_dir) = create_test_installer();
|
||||
|
||||
let result = installer.uninstall("non-existent").await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
InstallError::NotFound(name) => {
|
||||
assert_eq!(name, "non-existent");
|
||||
}
|
||||
e => panic!("期望 NotFound 错误,实际: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_installed() {
|
||||
let (installer, _plugins_dir, temp_dir, _db_dir) = create_test_installer();
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
|
||||
// 安装多个插件
|
||||
let pkg1 = create_test_plugin_zip(temp_dir.path(), "plugin-a", "1.0.0");
|
||||
let pkg2 = create_test_plugin_zip(temp_dir.path(), "plugin-b", "2.0.0");
|
||||
|
||||
installer.install_from_file(&pkg1, &progress).await.unwrap();
|
||||
installer.install_from_file(&pkg2, &progress).await.unwrap();
|
||||
|
||||
// 列出已安装插件
|
||||
let plugins = installer.list_installed().unwrap();
|
||||
assert_eq!(plugins.len(), 2);
|
||||
|
||||
let names: Vec<&str> = plugins.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"plugin-a"));
|
||||
assert!(names.contains(&"plugin-b"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_plugin() {
|
||||
let (installer, _plugins_dir, temp_dir, _db_dir) = create_test_installer();
|
||||
let package_path = create_test_plugin_zip(temp_dir.path(), "get-test", "1.2.3");
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
installer
|
||||
.install_from_file(&package_path, &progress)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 获取存在的插件
|
||||
let plugin = installer.get_plugin("get-test").unwrap();
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.name, "get-test");
|
||||
assert_eq!(plugin.version, "1.2.3");
|
||||
|
||||
// 获取不存在的插件
|
||||
let not_found = installer.get_plugin("not-found").unwrap();
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// 属性测试模块
|
||||
///
|
||||
/// **Feature: plugin-installation**
|
||||
#[cfg(test)]
|
||||
mod property_tests {
|
||||
use super::*;
|
||||
use crate::plugin::installer::NoopProgressCallback;
|
||||
use proptest::prelude::*;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 生成有效的插件名称
|
||||
fn arb_valid_plugin_name() -> impl Strategy<Value = String> {
|
||||
"[a-z][a-z0-9_-]{2,20}".prop_map(|s| s)
|
||||
}
|
||||
|
||||
/// 生成有效的版本号
|
||||
fn arb_valid_version() -> impl Strategy<Value = String> {
|
||||
(1u32..10, 0u32..10, 0u32..10)
|
||||
.prop_map(|(major, minor, patch)| format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
/// 创建测试用的安装器
|
||||
fn create_test_installer_for_prop() -> (PluginInstaller, TempDir, TempDir, TempDir) {
|
||||
let plugins_dir = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let db_dir = TempDir::new().unwrap();
|
||||
let db_path = db_dir.path().join("test.db");
|
||||
|
||||
let installer = PluginInstaller::from_paths(
|
||||
plugins_dir.path().to_path_buf(),
|
||||
temp_dir.path().to_path_buf(),
|
||||
&db_path,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
(installer, plugins_dir, temp_dir, db_dir)
|
||||
}
|
||||
|
||||
/// 创建有效的测试插件包 (ZIP)
|
||||
fn create_valid_plugin_zip(dir: &Path, name: &str, version: &str) -> PathBuf {
|
||||
let file_path = dir.join(format!("{}.zip", name));
|
||||
|
||||
let manifest_json = format!(
|
||||
r#"{{
|
||||
"name": "{}",
|
||||
"version": "{}",
|
||||
"description": "Test plugin",
|
||||
"entry": "config.json",
|
||||
"plugin_type": "script",
|
||||
"hooks": []
|
||||
}}"#,
|
||||
name, version
|
||||
);
|
||||
|
||||
let config_json = r#"{"enabled": true}"#;
|
||||
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
zip.start_file("plugin.json", options).unwrap();
|
||||
zip.write_all(manifest_json.as_bytes()).unwrap();
|
||||
|
||||
zip.start_file("config.json", options).unwrap();
|
||||
zip.write_all(config_json.as_bytes()).unwrap();
|
||||
|
||||
zip.finish().unwrap();
|
||||
|
||||
file_path
|
||||
}
|
||||
|
||||
/// 创建无效的插件包(缺少 plugin.json)
|
||||
fn create_invalid_plugin_zip_no_manifest(dir: &Path, name: &str) -> PathBuf {
|
||||
let file_path = dir.join(format!("{}-invalid.zip", name));
|
||||
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
// 只添加一个普通文件,不添加 plugin.json
|
||||
zip.start_file("readme.txt", options).unwrap();
|
||||
zip.write_all(b"This is not a valid plugin").unwrap();
|
||||
|
||||
zip.finish().unwrap();
|
||||
|
||||
file_path
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(5))]
|
||||
|
||||
/// **Feature: plugin-installation, 属性 2: 安装原子性**
|
||||
///
|
||||
/// *对于任意*插件安装尝试,要么插件完全安装成功(文件已复制、注册表已更新),
|
||||
/// 要么系统不做任何更改(失败时回滚)。
|
||||
///
|
||||
/// **验证需求: 1.2, 1.3, 3.4**
|
||||
#[test]
|
||||
fn prop_install_atomicity_success(
|
||||
name in arb_valid_plugin_name(),
|
||||
version in arb_valid_version()
|
||||
) {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let (installer, plugins_dir, temp_dir, _db_dir) = create_test_installer_for_prop();
|
||||
let package_path = create_valid_plugin_zip(temp_dir.path(), &name, &version);
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
let result = installer.install_from_file(&package_path, &progress).await;
|
||||
|
||||
// 成功安装时,验证所有状态都已更新
|
||||
prop_assert!(result.is_ok(), "安装应该成功: {:?}", result);
|
||||
|
||||
let installed = result.unwrap();
|
||||
|
||||
// 验证注册表已更新
|
||||
let is_registered = installer.is_installed(&name).unwrap();
|
||||
prop_assert!(is_registered, "插件应该在注册表中");
|
||||
|
||||
// 验证文件已复制
|
||||
let plugin_dir = plugins_dir.path().join(&name);
|
||||
prop_assert!(plugin_dir.exists(), "插件目录应该存在");
|
||||
prop_assert!(
|
||||
plugin_dir.join("plugin.json").exists(),
|
||||
"plugin.json 应该存在"
|
||||
);
|
||||
|
||||
// 验证返回的信息正确
|
||||
prop_assert_eq!(installed.name, name);
|
||||
prop_assert_eq!(installed.version, version);
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
/// **Feature: plugin-installation, 属性 2: 安装原子性 (失败情况)**
|
||||
///
|
||||
/// *对于任意*无效的插件包,安装失败后系统状态应该保持不变。
|
||||
///
|
||||
/// **验证需求: 1.2, 1.3, 3.4**
|
||||
#[test]
|
||||
fn prop_install_atomicity_failure(name in arb_valid_plugin_name()) {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let (installer, plugins_dir, temp_dir, _db_dir) = create_test_installer_for_prop();
|
||||
|
||||
// 记录安装前的状态
|
||||
let plugins_before = installer.list_installed().unwrap();
|
||||
let plugins_dir_empty_before = fs::read_dir(plugins_dir.path())
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(true);
|
||||
|
||||
// 创建无效的插件包
|
||||
let invalid_package = create_invalid_plugin_zip_no_manifest(temp_dir.path(), &name);
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
let result = installer.install_from_file(&invalid_package, &progress).await;
|
||||
|
||||
// 安装应该失败
|
||||
prop_assert!(result.is_err(), "无效包安装应该失败");
|
||||
|
||||
// 验证注册表未更改
|
||||
let plugins_after = installer.list_installed().unwrap();
|
||||
prop_assert_eq!(
|
||||
plugins_before.len(),
|
||||
plugins_after.len(),
|
||||
"注册表不应该有变化"
|
||||
);
|
||||
|
||||
// 验证插件目录未更改
|
||||
let plugins_dir_empty_after = fs::read_dir(plugins_dir.path())
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(true);
|
||||
prop_assert_eq!(
|
||||
plugins_dir_empty_before,
|
||||
plugins_dir_empty_after,
|
||||
"插件目录不应该有变化"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
/// **Feature: plugin-installation, 属性 3: 卸载完整性**
|
||||
///
|
||||
/// *对于任意*已安装的插件,卸载后,插件文件必须从 plugins 目录中删除,
|
||||
/// 且注册表条目必须被删除。
|
||||
///
|
||||
/// **验证需求: 4.2, 4.3**
|
||||
#[test]
|
||||
fn prop_uninstall_completeness(
|
||||
name in arb_valid_plugin_name(),
|
||||
version in arb_valid_version()
|
||||
) {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let (installer, plugins_dir, temp_dir, _db_dir) = create_test_installer_for_prop();
|
||||
|
||||
// 先安装插件
|
||||
let package_path = create_valid_plugin_zip(temp_dir.path(), &name, &version);
|
||||
let progress = NoopProgressCallback;
|
||||
let install_result = installer.install_from_file(&package_path, &progress).await;
|
||||
prop_assert!(install_result.is_ok(), "安装应该成功");
|
||||
|
||||
// 验证安装成功
|
||||
let plugin_dir = plugins_dir.path().join(&name);
|
||||
prop_assert!(plugin_dir.exists(), "安装后插件目录应该存在");
|
||||
prop_assert!(installer.is_installed(&name).unwrap(), "安装后应该在注册表中");
|
||||
|
||||
// 执行卸载
|
||||
let uninstall_result = installer.uninstall(&name).await;
|
||||
prop_assert!(uninstall_result.is_ok(), "卸载应该成功: {:?}", uninstall_result);
|
||||
|
||||
// 验证文件已删除
|
||||
prop_assert!(
|
||||
!plugin_dir.exists(),
|
||||
"卸载后插件目录应该被删除"
|
||||
);
|
||||
|
||||
// 验证注册表条目已删除
|
||||
prop_assert!(
|
||||
!installer.is_installed(&name).unwrap(),
|
||||
"卸载后不应该在注册表中"
|
||||
);
|
||||
|
||||
// 验证获取插件返回 None
|
||||
let plugin = installer.get_plugin(&name).unwrap();
|
||||
prop_assert!(plugin.is_none(), "卸载后获取插件应该返回 None");
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
/// **Feature: plugin-installation, 属性 3: 卸载完整性 (不存在的插件)**
|
||||
///
|
||||
/// *对于任意*不存在的插件 ID,卸载应该返回 NotFound 错误。
|
||||
///
|
||||
/// **验证需求: 4.2, 4.3**
|
||||
#[test]
|
||||
fn prop_uninstall_not_found(name in arb_valid_plugin_name()) {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let (installer, _plugins_dir, _temp_dir, _db_dir) = create_test_installer_for_prop();
|
||||
|
||||
// 尝试卸载不存在的插件
|
||||
let result = installer.uninstall(&name).await;
|
||||
|
||||
// 应该返回 NotFound 错误
|
||||
prop_assert!(result.is_err(), "卸载不存在的插件应该失败");
|
||||
match result.unwrap_err() {
|
||||
InstallError::NotFound(id) => {
|
||||
prop_assert_eq!(id, name, "错误应该包含正确的插件 ID");
|
||||
}
|
||||
e => {
|
||||
prop_assert!(false, "期望 NotFound 错误,实际: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! 插件安装器模块
|
||||
//!
|
||||
//! 提供插件安装、卸载和管理功能:
|
||||
//! - 从本地文件安装插件
|
||||
//! - 从 URL(如 GitHub releases)下载安装插件
|
||||
//! - 插件包验证
|
||||
//! - 插件注册表管理
|
||||
//! - 安装进度回调
|
||||
|
||||
mod downloader;
|
||||
mod installer;
|
||||
mod registry;
|
||||
mod types;
|
||||
mod validator;
|
||||
|
||||
pub use downloader::PluginDownloader;
|
||||
pub use installer::PluginInstaller;
|
||||
pub use registry::PluginRegistry;
|
||||
pub use types::{
|
||||
GitHubRelease, InstallError, InstallProgress, InstallSource, InstallStage, InstalledPlugin,
|
||||
NoopProgressCallback, PackageFormat, ProgressCallback,
|
||||
};
|
||||
pub use validator::PackageValidator;
|
||||
@@ -0,0 +1,421 @@
|
||||
//! 插件注册表
|
||||
//!
|
||||
//! 管理已安装插件的元数据
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::types::{InstallError, InstallSource, InstalledPlugin};
|
||||
|
||||
/// 插件注册表
|
||||
///
|
||||
/// 管理已安装插件的元数据
|
||||
pub struct PluginRegistry {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
/// 创建新的注册表实例
|
||||
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
/// 从数据库路径创建注册表
|
||||
pub fn from_path(db_path: &Path) -> Result<Self, InstallError> {
|
||||
let conn =
|
||||
Connection::open(db_path).map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 初始化数据库表
|
||||
pub fn init_tables(&self) -> Result<(), InstallError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS installed_plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
description TEXT,
|
||||
author TEXT,
|
||||
install_path TEXT NOT NULL,
|
||||
installed_at TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_data TEXT,
|
||||
enabled INTEGER DEFAULT 1
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 注册插件
|
||||
///
|
||||
/// _需求: 1.2_
|
||||
pub fn register(&self, plugin: &InstalledPlugin) -> Result<(), InstallError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let (source_type, source_data) = serialize_source(&plugin.source);
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO installed_plugins
|
||||
(id, name, version, description, author, install_path, installed_at, source_type, source_data, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
plugin.id,
|
||||
plugin.name,
|
||||
plugin.version,
|
||||
plugin.description,
|
||||
plugin.author,
|
||||
plugin.install_path.to_string_lossy().to_string(),
|
||||
plugin.installed_at.to_rfc3339(),
|
||||
source_type,
|
||||
source_data,
|
||||
plugin.enabled as i32,
|
||||
],
|
||||
)
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 注销插件
|
||||
///
|
||||
/// _需求: 4.2_
|
||||
pub fn unregister(&self, plugin_id: &str) -> Result<(), InstallError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let rows_affected = conn
|
||||
.execute(
|
||||
"DELETE FROM installed_plugins WHERE id = ?1",
|
||||
params![plugin_id],
|
||||
)
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
return Err(InstallError::NotFound(plugin_id.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取插件信息
|
||||
pub fn get(&self, plugin_id: &str) -> Result<Option<InstalledPlugin>, InstallError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let result = conn
|
||||
.query_row(
|
||||
"SELECT id, name, version, description, author, install_path, installed_at, source_type, source_data, enabled
|
||||
FROM installed_plugins WHERE id = ?1",
|
||||
params![plugin_id],
|
||||
|row| {
|
||||
Ok(PluginRow {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
version: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
author: row.get(4)?,
|
||||
install_path: row.get(5)?,
|
||||
installed_at: row.get(6)?,
|
||||
source_type: row.get(7)?,
|
||||
source_data: row.get(8)?,
|
||||
enabled: row.get(9)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
match result {
|
||||
Some(row) => Ok(Some(row.into_installed_plugin()?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有插件
|
||||
pub fn list(&self) -> Result<Vec<InstalledPlugin>, InstallError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, version, description, author, install_path, installed_at, source_type, source_data, enabled
|
||||
FROM installed_plugins ORDER BY installed_at DESC",
|
||||
)
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(PluginRow {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
version: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
author: row.get(4)?,
|
||||
install_path: row.get(5)?,
|
||||
installed_at: row.get(6)?,
|
||||
source_type: row.get(7)?,
|
||||
source_data: row.get(8)?,
|
||||
enabled: row.get(9)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let mut plugins = Vec::new();
|
||||
for row in rows {
|
||||
let row = row.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
plugins.push(row.into_installed_plugin()?);
|
||||
}
|
||||
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
/// 检查插件是否存在
|
||||
pub fn exists(&self, plugin_id: &str) -> Result<bool, InstallError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let count: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM installed_plugins WHERE id = ?1",
|
||||
params![plugin_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// 更新插件启用状态
|
||||
pub fn set_enabled(&self, plugin_id: &str, enabled: bool) -> Result<(), InstallError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let rows_affected = conn
|
||||
.execute(
|
||||
"UPDATE installed_plugins SET enabled = ?1 WHERE id = ?2",
|
||||
params![enabled as i32, plugin_id],
|
||||
)
|
||||
.map_err(|e| InstallError::DatabaseError(e.to_string()))?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
return Err(InstallError::NotFound(plugin_id.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据库行结构
|
||||
struct PluginRow {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
description: Option<String>,
|
||||
author: Option<String>,
|
||||
install_path: String,
|
||||
installed_at: String,
|
||||
source_type: String,
|
||||
source_data: Option<String>,
|
||||
enabled: i32,
|
||||
}
|
||||
|
||||
impl PluginRow {
|
||||
fn into_installed_plugin(self) -> Result<InstalledPlugin, InstallError> {
|
||||
let source = deserialize_source(&self.source_type, self.source_data.as_deref())?;
|
||||
let installed_at = chrono::DateTime::parse_from_rfc3339(&self.installed_at)
|
||||
.map_err(|e| InstallError::DatabaseError(format!("无效的时间格式: {}", e)))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
Ok(InstalledPlugin {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
version: self.version,
|
||||
description: self.description.unwrap_or_default(),
|
||||
author: self.author,
|
||||
install_path: std::path::PathBuf::from(self.install_path),
|
||||
installed_at,
|
||||
source,
|
||||
enabled: self.enabled != 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 序列化安装来源
|
||||
fn serialize_source(source: &InstallSource) -> (String, Option<String>) {
|
||||
match source {
|
||||
InstallSource::Local { path } => ("local".to_string(), Some(path.clone())),
|
||||
InstallSource::Url { url } => ("url".to_string(), Some(url.clone())),
|
||||
InstallSource::GitHub { owner, repo, tag } => {
|
||||
let data = serde_json::json!({
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"tag": tag
|
||||
});
|
||||
("github".to_string(), Some(data.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 反序列化安装来源
|
||||
fn deserialize_source(
|
||||
source_type: &str,
|
||||
source_data: Option<&str>,
|
||||
) -> Result<InstallSource, InstallError> {
|
||||
match source_type {
|
||||
"local" => Ok(InstallSource::Local {
|
||||
path: source_data.unwrap_or_default().to_string(),
|
||||
}),
|
||||
"url" => Ok(InstallSource::Url {
|
||||
url: source_data.unwrap_or_default().to_string(),
|
||||
}),
|
||||
"github" => {
|
||||
let data: serde_json::Value = serde_json::from_str(source_data.unwrap_or("{}"))?;
|
||||
Ok(InstallSource::GitHub {
|
||||
owner: data["owner"].as_str().unwrap_or_default().to_string(),
|
||||
repo: data["repo"].as_str().unwrap_or_default().to_string(),
|
||||
tag: data["tag"].as_str().unwrap_or_default().to_string(),
|
||||
})
|
||||
}
|
||||
_ => Err(InstallError::DatabaseError(format!(
|
||||
"未知的来源类型: {}",
|
||||
source_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_registry() -> PluginRegistry {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
let registry = PluginRegistry {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
registry.init_tables().unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn create_test_plugin(id: &str) -> InstalledPlugin {
|
||||
InstalledPlugin {
|
||||
id: id.to_string(),
|
||||
name: format!("Test Plugin {}", id),
|
||||
version: "1.0.0".to_string(),
|
||||
description: "A test plugin".to_string(),
|
||||
author: Some("Test Author".to_string()),
|
||||
install_path: PathBuf::from(format!("/plugins/{}", id)),
|
||||
installed_at: chrono::Utc::now(),
|
||||
source: InstallSource::Local {
|
||||
path: "/tmp/plugin.zip".to_string(),
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_and_get() {
|
||||
let registry = create_test_registry();
|
||||
let plugin = create_test_plugin("test-1");
|
||||
|
||||
registry.register(&plugin).unwrap();
|
||||
|
||||
let retrieved = registry.get("test-1").unwrap().unwrap();
|
||||
assert_eq!(retrieved.id, "test-1");
|
||||
assert_eq!(retrieved.name, "Test Plugin test-1");
|
||||
assert_eq!(retrieved.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unregister() {
|
||||
let registry = create_test_registry();
|
||||
let plugin = create_test_plugin("test-2");
|
||||
|
||||
registry.register(&plugin).unwrap();
|
||||
assert!(registry.exists("test-2").unwrap());
|
||||
|
||||
registry.unregister("test-2").unwrap();
|
||||
assert!(!registry.exists("test-2").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unregister_not_found() {
|
||||
let registry = create_test_registry();
|
||||
let result = registry.unregister("non-existent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list() {
|
||||
let registry = create_test_registry();
|
||||
|
||||
registry.register(&create_test_plugin("test-a")).unwrap();
|
||||
registry.register(&create_test_plugin("test-b")).unwrap();
|
||||
|
||||
let plugins = registry.list().unwrap();
|
||||
assert_eq!(plugins.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_enabled() {
|
||||
let registry = create_test_registry();
|
||||
let plugin = create_test_plugin("test-3");
|
||||
|
||||
registry.register(&plugin).unwrap();
|
||||
|
||||
registry.set_enabled("test-3", false).unwrap();
|
||||
let retrieved = registry.get("test-3").unwrap().unwrap();
|
||||
assert!(!retrieved.enabled);
|
||||
|
||||
registry.set_enabled("test-3", true).unwrap();
|
||||
let retrieved = registry.get("test-3").unwrap().unwrap();
|
||||
assert!(retrieved.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_source_serialization() {
|
||||
let registry = create_test_registry();
|
||||
let mut plugin = create_test_plugin("test-github");
|
||||
plugin.source = InstallSource::GitHub {
|
||||
owner: "user".to_string(),
|
||||
repo: "repo".to_string(),
|
||||
tag: "v1.0.0".to_string(),
|
||||
};
|
||||
|
||||
registry.register(&plugin).unwrap();
|
||||
|
||||
let retrieved = registry.get("test-github").unwrap().unwrap();
|
||||
match retrieved.source {
|
||||
InstallSource::GitHub { owner, repo, tag } => {
|
||||
assert_eq!(owner, "user");
|
||||
assert_eq!(repo, "repo");
|
||||
assert_eq!(tag, "v1.0.0");
|
||||
}
|
||||
_ => panic!("Expected GitHub source"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! 插件安装器类型定义
|
||||
//!
|
||||
//! 定义安装相关的错误类型、进度类型和数据结构
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
/// 安装错误类型
|
||||
///
|
||||
/// 定义所有安装相关的错误变体
|
||||
/// _需求: 1.4, 2.3, 3.4_
|
||||
#[derive(Error, Debug)]
|
||||
pub enum InstallError {
|
||||
/// 下载失败
|
||||
#[error("下载失败: {0}")]
|
||||
DownloadFailed(String),
|
||||
|
||||
/// 包格式无效
|
||||
#[error("包格式无效: {0}")]
|
||||
InvalidPackage(String),
|
||||
|
||||
/// 清单无效
|
||||
#[error("清单无效: {0}")]
|
||||
InvalidManifest(String),
|
||||
|
||||
/// 解压失败
|
||||
#[error("解压失败: {0}")]
|
||||
ExtractFailed(String),
|
||||
|
||||
/// 安装失败
|
||||
#[error("安装失败: {0}")]
|
||||
InstallFailed(String),
|
||||
|
||||
/// 插件已存在
|
||||
#[error("插件已存在: {0}")]
|
||||
AlreadyExists(String),
|
||||
|
||||
/// 插件不存在
|
||||
#[error("插件不存在: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// 验证失败
|
||||
#[error("验证失败: {0}")]
|
||||
ValidationFailed(String),
|
||||
|
||||
/// 校验和不匹配
|
||||
#[error("校验和不匹配: 期望 {expected}, 实际 {actual}")]
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
|
||||
/// IO 错误
|
||||
#[error("IO 错误: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
/// 网络错误
|
||||
#[error("网络错误: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
/// JSON 解析错误
|
||||
#[error("JSON 解析错误: {0}")]
|
||||
JsonError(#[from] serde_json::Error),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
/// URL 解析错误
|
||||
#[error("URL 解析错误: {0}")]
|
||||
UrlParseError(String),
|
||||
|
||||
/// 不支持的平台
|
||||
#[error("不支持的平台: {0}")]
|
||||
UnsupportedPlatform(String),
|
||||
}
|
||||
|
||||
/// 安装阶段
|
||||
///
|
||||
/// 表示安装过程中的各个阶段
|
||||
/// _需求: 2.4, 3.1, 3.2_
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InstallStage {
|
||||
/// 下载中
|
||||
Downloading,
|
||||
/// 验证中
|
||||
Validating,
|
||||
/// 解压中
|
||||
Extracting,
|
||||
/// 安装中
|
||||
Installing,
|
||||
/// 注册中
|
||||
Registering,
|
||||
/// 完成
|
||||
Complete,
|
||||
/// 失败
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InstallStage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
InstallStage::Downloading => write!(f, "downloading"),
|
||||
InstallStage::Validating => write!(f, "validating"),
|
||||
InstallStage::Extracting => write!(f, "extracting"),
|
||||
InstallStage::Installing => write!(f, "installing"),
|
||||
InstallStage::Registering => write!(f, "registering"),
|
||||
InstallStage::Complete => write!(f, "complete"),
|
||||
InstallStage::Failed => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装进度
|
||||
///
|
||||
/// 表示安装过程中的进度信息
|
||||
/// _需求: 2.4, 3.1, 3.2_
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstallProgress {
|
||||
/// 当前阶段
|
||||
pub stage: InstallStage,
|
||||
/// 进度百分比 (0-100)
|
||||
pub percent: u8,
|
||||
/// 状态消息
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl InstallProgress {
|
||||
/// 创建新的进度实例
|
||||
pub fn new(stage: InstallStage, percent: u8, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
stage,
|
||||
percent: percent.min(100),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建下载阶段进度
|
||||
pub fn downloading(percent: u8, message: impl Into<String>) -> Self {
|
||||
Self::new(InstallStage::Downloading, percent, message)
|
||||
}
|
||||
|
||||
/// 创建验证阶段进度
|
||||
pub fn validating(message: impl Into<String>) -> Self {
|
||||
Self::new(InstallStage::Validating, 0, message)
|
||||
}
|
||||
|
||||
/// 创建解压阶段进度
|
||||
pub fn extracting(percent: u8, message: impl Into<String>) -> Self {
|
||||
Self::new(InstallStage::Extracting, percent, message)
|
||||
}
|
||||
|
||||
/// 创建安装阶段进度
|
||||
pub fn installing(percent: u8, message: impl Into<String>) -> Self {
|
||||
Self::new(InstallStage::Installing, percent, message)
|
||||
}
|
||||
|
||||
/// 创建注册阶段进度
|
||||
pub fn registering(message: impl Into<String>) -> Self {
|
||||
Self::new(InstallStage::Registering, 90, message)
|
||||
}
|
||||
|
||||
/// 创建完成状态
|
||||
pub fn complete(message: impl Into<String>) -> Self {
|
||||
Self::new(InstallStage::Complete, 100, message)
|
||||
}
|
||||
|
||||
/// 创建失败状态
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self::new(InstallStage::Failed, 0, message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度回调 trait
|
||||
///
|
||||
/// 用于接收安装进度更新
|
||||
pub trait ProgressCallback: Send + Sync {
|
||||
/// 进度更新回调
|
||||
fn on_progress(&self, progress: InstallProgress);
|
||||
}
|
||||
|
||||
/// 空进度回调实现
|
||||
///
|
||||
/// 用于不需要进度回调的场景
|
||||
pub struct NoopProgressCallback;
|
||||
|
||||
impl ProgressCallback for NoopProgressCallback {
|
||||
fn on_progress(&self, _progress: InstallProgress) {
|
||||
// 不做任何事
|
||||
}
|
||||
}
|
||||
|
||||
/// 函数进度回调实现
|
||||
///
|
||||
/// 将闭包包装为 ProgressCallback
|
||||
pub struct FnProgressCallback<F>
|
||||
where
|
||||
F: Fn(InstallProgress) + Send + Sync,
|
||||
{
|
||||
callback: F,
|
||||
}
|
||||
|
||||
impl<F> FnProgressCallback<F>
|
||||
where
|
||||
F: Fn(InstallProgress) + Send + Sync,
|
||||
{
|
||||
/// 创建新的函数回调
|
||||
pub fn new(callback: F) -> Self {
|
||||
Self { callback }
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> ProgressCallback for FnProgressCallback<F>
|
||||
where
|
||||
F: Fn(InstallProgress) + Send + Sync,
|
||||
{
|
||||
fn on_progress(&self, progress: InstallProgress) {
|
||||
(self.callback)(progress);
|
||||
}
|
||||
}
|
||||
|
||||
/// 包格式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PackageFormat {
|
||||
/// ZIP 格式
|
||||
Zip,
|
||||
/// tar.gz 格式
|
||||
TarGz,
|
||||
}
|
||||
|
||||
impl PackageFormat {
|
||||
/// 从文件扩展名检测格式
|
||||
pub fn from_extension(path: &std::path::Path) -> Option<Self> {
|
||||
let file_name = path.file_name()?.to_str()?;
|
||||
if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
|
||||
Some(PackageFormat::TarGz)
|
||||
} else if file_name.ends_with(".zip") {
|
||||
Some(PackageFormat::Zip)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum InstallSource {
|
||||
/// 本地文件
|
||||
Local {
|
||||
/// 原始文件路径
|
||||
path: String,
|
||||
},
|
||||
/// URL 下载
|
||||
Url {
|
||||
/// 下载 URL
|
||||
url: String,
|
||||
},
|
||||
/// GitHub release
|
||||
GitHub {
|
||||
/// 仓库 owner
|
||||
owner: String,
|
||||
/// 仓库名
|
||||
repo: String,
|
||||
/// release tag
|
||||
tag: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// GitHub release 信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitHubRelease {
|
||||
/// 仓库 owner
|
||||
pub owner: String,
|
||||
/// 仓库名
|
||||
pub repo: String,
|
||||
/// release tag
|
||||
pub tag: String,
|
||||
/// 资产文件名
|
||||
pub asset_name: Option<String>,
|
||||
}
|
||||
|
||||
impl GitHubRelease {
|
||||
/// 构建下载 URL
|
||||
pub fn download_url(&self, asset_name: &str) -> String {
|
||||
format!(
|
||||
"https://github.com/{}/{}/releases/download/{}/{}",
|
||||
self.owner, self.repo, self.tag, asset_name
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建 API URL(获取 release 信息)
|
||||
pub fn api_url(&self) -> String {
|
||||
format!(
|
||||
"https://api.github.com/repos/{}/{}/releases/tags/{}",
|
||||
self.owner, self.repo, self.tag
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建最新 release API URL
|
||||
pub fn latest_api_url(&self) -> String {
|
||||
format!(
|
||||
"https://api.github.com/repos/{}/{}/releases/latest",
|
||||
self.owner, self.repo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 已安装插件信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstalledPlugin {
|
||||
/// 插件 ID (通常是 name)
|
||||
pub id: String,
|
||||
/// 插件名称
|
||||
pub name: String,
|
||||
/// 版本
|
||||
pub version: String,
|
||||
/// 描述
|
||||
pub description: String,
|
||||
/// 作者
|
||||
pub author: Option<String>,
|
||||
/// 安装路径
|
||||
pub install_path: PathBuf,
|
||||
/// 安装时间
|
||||
pub installed_at: DateTime<Utc>,
|
||||
/// 安装来源
|
||||
pub source: InstallSource,
|
||||
/// 是否启用
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl InstalledPlugin {
|
||||
/// 创建新的已安装插件信息
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
version: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
install_path: PathBuf,
|
||||
source: InstallSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
description: description.into(),
|
||||
author: None,
|
||||
install_path,
|
||||
installed_at: Utc::now(),
|
||||
source,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置作者
|
||||
pub fn with_author(mut self, author: impl Into<String>) -> Self {
|
||||
self.author = Some(author.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置启用状态
|
||||
pub fn with_enabled(mut self, enabled: bool) -> Self {
|
||||
self.enabled = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_install_progress_creation() {
|
||||
let progress = InstallProgress::downloading(50, "下载中...");
|
||||
assert_eq!(progress.stage, InstallStage::Downloading);
|
||||
assert_eq!(progress.percent, 50);
|
||||
assert_eq!(progress.message, "下载中...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_progress_percent_capped() {
|
||||
let progress = InstallProgress::new(InstallStage::Installing, 150, "测试");
|
||||
assert_eq!(progress.percent, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_package_format_detection() {
|
||||
use std::path::Path;
|
||||
|
||||
assert_eq!(
|
||||
PackageFormat::from_extension(Path::new("plugin.zip")),
|
||||
Some(PackageFormat::Zip)
|
||||
);
|
||||
assert_eq!(
|
||||
PackageFormat::from_extension(Path::new("plugin.tar.gz")),
|
||||
Some(PackageFormat::TarGz)
|
||||
);
|
||||
assert_eq!(
|
||||
PackageFormat::from_extension(Path::new("plugin.tgz")),
|
||||
Some(PackageFormat::TarGz)
|
||||
);
|
||||
assert_eq!(PackageFormat::from_extension(Path::new("plugin.txt")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_release_urls() {
|
||||
let release = GitHubRelease {
|
||||
owner: "user".to_string(),
|
||||
repo: "repo".to_string(),
|
||||
tag: "v1.0.0".to_string(),
|
||||
asset_name: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
release.download_url("plugin.zip"),
|
||||
"https://github.com/user/repo/releases/download/v1.0.0/plugin.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
release.api_url(),
|
||||
"https://api.github.com/repos/user/repo/releases/tags/v1.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_source_serialization() {
|
||||
let source = InstallSource::GitHub {
|
||||
owner: "user".to_string(),
|
||||
repo: "repo".to_string(),
|
||||
tag: "v1.0.0".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&source).unwrap();
|
||||
assert!(json.contains("\"type\":\"github\""));
|
||||
|
||||
let parsed: InstallSource = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
InstallSource::GitHub { owner, repo, tag } => {
|
||||
assert_eq!(owner, "user");
|
||||
assert_eq!(repo, "repo");
|
||||
assert_eq!(tag, "v1.0.0");
|
||||
}
|
||||
_ => panic!("Expected GitHub source"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
//! 插件包验证器
|
||||
//!
|
||||
//! 验证插件包格式和内容
|
||||
//!
|
||||
//! 主要功能:
|
||||
//! - 验证包格式(zip/tar.gz)
|
||||
//! - 验证清单文件必需字段
|
||||
//! - 验证包完整性(校验和)
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use super::types::{InstallError, PackageFormat};
|
||||
use crate::plugin::PluginManifest;
|
||||
|
||||
/// 包验证器
|
||||
///
|
||||
/// 验证插件包格式和内容
|
||||
pub struct PackageValidator;
|
||||
|
||||
impl PackageValidator {
|
||||
/// 创建新的验证器
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 验证包格式(zip/tar.gz)
|
||||
///
|
||||
/// 检测文件格式,验证压缩包完整性
|
||||
/// _需求: 1.1_
|
||||
pub fn validate_format(&self, path: &Path) -> Result<PackageFormat, InstallError> {
|
||||
// 首先检查文件是否存在
|
||||
if !path.exists() {
|
||||
return Err(InstallError::InvalidPackage(format!(
|
||||
"文件不存在: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
if metadata.len() == 0 {
|
||||
return Err(InstallError::InvalidPackage("文件为空".to_string()));
|
||||
}
|
||||
|
||||
// 从扩展名检测格式
|
||||
let format = PackageFormat::from_extension(path).ok_or_else(|| {
|
||||
InstallError::InvalidPackage(format!(
|
||||
"不支持的包格式,仅支持 .zip 和 .tar.gz: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
// 验证文件魔数
|
||||
self.validate_magic_bytes(path, format)?;
|
||||
|
||||
// 验证压缩包完整性
|
||||
self.validate_archive_integrity(path, format)?;
|
||||
|
||||
Ok(format)
|
||||
}
|
||||
|
||||
/// 验证文件魔数
|
||||
fn validate_magic_bytes(&self, path: &Path, format: PackageFormat) -> Result<(), InstallError> {
|
||||
let file = File::open(path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut magic = [0u8; 4];
|
||||
|
||||
reader
|
||||
.read_exact(&mut magic)
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("无法读取文件头: {}", e)))?;
|
||||
|
||||
match format {
|
||||
PackageFormat::Zip => {
|
||||
// ZIP 文件魔数: PK\x03\x04 (正常文件) 或 PK\x05\x06 (空压缩包)
|
||||
if magic[0..2] != [0x50, 0x4B] {
|
||||
return Err(InstallError::InvalidPackage(
|
||||
"无效的 ZIP 文件格式".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
PackageFormat::TarGz => {
|
||||
// Gzip 文件魔数: \x1f\x8b
|
||||
if magic[0..2] != [0x1f, 0x8b] {
|
||||
return Err(InstallError::InvalidPackage(
|
||||
"无效的 tar.gz 文件格式".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证压缩包完整性
|
||||
///
|
||||
/// 尝试读取压缩包结构,确保文件未损坏
|
||||
fn validate_archive_integrity(
|
||||
&self,
|
||||
path: &Path,
|
||||
format: PackageFormat,
|
||||
) -> Result<(), InstallError> {
|
||||
match format {
|
||||
PackageFormat::Zip => self.validate_zip_integrity(path),
|
||||
PackageFormat::TarGz => self.validate_targz_integrity(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证 ZIP 文件完整性
|
||||
fn validate_zip_integrity(&self, path: &Path) -> Result<(), InstallError> {
|
||||
let file = File::open(path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("无法读取 ZIP 文件: {}", e)))?;
|
||||
|
||||
// 检查是否为空压缩包
|
||||
if archive.len() == 0 {
|
||||
return Err(InstallError::InvalidPackage("ZIP 压缩包为空".to_string()));
|
||||
}
|
||||
|
||||
// 尝试读取每个文件的元数据以验证完整性
|
||||
for i in 0..archive.len() {
|
||||
let file = archive.by_index(i).map_err(|e| {
|
||||
InstallError::InvalidPackage(format!("ZIP 文件损坏,无法读取条目 {}: {}", i, e))
|
||||
})?;
|
||||
|
||||
// 验证文件名有效
|
||||
if file.name().is_empty() {
|
||||
return Err(InstallError::InvalidPackage(format!(
|
||||
"ZIP 条目 {} 的文件名无效",
|
||||
i
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证 tar.gz 文件完整性
|
||||
fn validate_targz_integrity(&self, path: &Path) -> Result<(), InstallError> {
|
||||
let file = File::open(path)?;
|
||||
let gz = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
|
||||
let mut entry_count = 0;
|
||||
for entry in archive
|
||||
.entries()
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("无法读取 tar.gz 文件: {}", e)))?
|
||||
{
|
||||
let entry = entry
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("tar.gz 文件损坏: {}", e)))?;
|
||||
|
||||
// 验证路径有效
|
||||
let path = entry
|
||||
.path()
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("tar.gz 条目路径无效: {}", e)))?;
|
||||
|
||||
if path.to_string_lossy().is_empty() {
|
||||
return Err(InstallError::InvalidPackage(
|
||||
"tar.gz 条目路径为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
entry_count += 1;
|
||||
}
|
||||
|
||||
if entry_count == 0 {
|
||||
return Err(InstallError::InvalidPackage(
|
||||
"tar.gz 压缩包为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证清单文件
|
||||
///
|
||||
/// 验证 plugin.json 必需字段
|
||||
/// _需求: 5.1, 5.2, 5.3, 5.4_
|
||||
pub fn validate_manifest(&self, manifest: &PluginManifest) -> Result<(), InstallError> {
|
||||
// 验证必需字段: name (5.1)
|
||||
if manifest.name.is_empty() {
|
||||
return Err(InstallError::InvalidManifest(
|
||||
"插件名称 (name) 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证必需字段: version (5.1)
|
||||
if manifest.version.is_empty() {
|
||||
return Err(InstallError::InvalidManifest(
|
||||
"插件版本 (version) 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证名称格式(只允许字母、数字、连字符、下划线)
|
||||
if !Self::is_valid_name(&manifest.name) {
|
||||
return Err(InstallError::InvalidManifest(
|
||||
"插件名称只能包含字母、数字、连字符和下划线".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证名称长度
|
||||
if manifest.name.len() > 64 {
|
||||
return Err(InstallError::InvalidManifest(
|
||||
"插件名称长度不能超过 64 个字符".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证版本格式(简单的 semver 检查)
|
||||
if !Self::is_valid_version(&manifest.version) {
|
||||
return Err(InstallError::InvalidManifest(format!(
|
||||
"无效的版本格式: {},期望 semver 格式如 1.0.0",
|
||||
manifest.version
|
||||
)));
|
||||
}
|
||||
|
||||
// 验证 plugin_type (5.2) - 类型已通过 serde 反序列化验证
|
||||
|
||||
// 验证 entry 字段 (5.3)
|
||||
if manifest.entry.is_empty() {
|
||||
return Err(InstallError::InvalidManifest(
|
||||
"入口文件 (entry) 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证 hooks 字段格式 (5.3)
|
||||
for hook in &manifest.hooks {
|
||||
if !Self::is_valid_hook_name(hook) {
|
||||
return Err(InstallError::InvalidManifest(format!(
|
||||
"无效的钩子名称: {}",
|
||||
hook
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证名称格式
|
||||
pub fn is_valid_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// 验证版本格式
|
||||
pub fn is_valid_version(version: &str) -> bool {
|
||||
if version.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 简单的 semver 验证:允许 x.y.z 或 x.y.z-suffix 格式
|
||||
let parts: Vec<&str> = version.split('-').collect();
|
||||
let version_part = parts[0];
|
||||
|
||||
let numbers: Vec<&str> = version_part.split('.').collect();
|
||||
if numbers.len() < 2 || numbers.len() > 3 {
|
||||
return false;
|
||||
}
|
||||
|
||||
numbers.iter().all(|n| n.parse::<u32>().is_ok())
|
||||
}
|
||||
|
||||
/// 验证钩子名称格式
|
||||
fn is_valid_hook_name(hook: &str) -> bool {
|
||||
!hook.is_empty()
|
||||
&& hook
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '_' || c == ':')
|
||||
}
|
||||
|
||||
/// 验证包完整性(校验和)
|
||||
///
|
||||
/// 如果提供了校验和,验证文件的 SHA256 哈希
|
||||
pub fn validate_integrity(
|
||||
&self,
|
||||
path: &Path,
|
||||
checksum: Option<&str>,
|
||||
) -> Result<(), InstallError> {
|
||||
let Some(expected) = checksum else {
|
||||
// 没有提供校验和,跳过验证
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let file = File::open(path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
|
||||
use sha2::Digest;
|
||||
loop {
|
||||
let bytes_read = reader.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
|
||||
if actual != expected.to_lowercase() {
|
||||
return Err(InstallError::ChecksumMismatch {
|
||||
expected: expected.to_string(),
|
||||
actual,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从压缩包中提取并验证清单
|
||||
///
|
||||
/// 读取压缩包中的 plugin.json 并验证
|
||||
pub fn extract_and_validate_manifest(
|
||||
&self,
|
||||
path: &Path,
|
||||
format: PackageFormat,
|
||||
) -> Result<PluginManifest, InstallError> {
|
||||
let manifest_content = match format {
|
||||
PackageFormat::Zip => self.extract_manifest_from_zip(path)?,
|
||||
PackageFormat::TarGz => self.extract_manifest_from_targz(path)?,
|
||||
};
|
||||
|
||||
let manifest: PluginManifest = serde_json::from_str(&manifest_content)
|
||||
.map_err(|e| InstallError::InvalidManifest(format!("plugin.json 解析失败: {}", e)))?;
|
||||
|
||||
self.validate_manifest(&manifest)?;
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// 从 ZIP 中提取 plugin.json
|
||||
fn extract_manifest_from_zip(&self, path: &Path) -> Result<String, InstallError> {
|
||||
let file = File::open(path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("无法读取 ZIP 文件: {}", e)))?;
|
||||
|
||||
// 查找 plugin.json(可能在根目录或子目录中)
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("无法读取 ZIP 条目: {}", e)))?;
|
||||
|
||||
let name = file.name().to_string();
|
||||
if name.ends_with("plugin.json") && !name.contains("__MACOSX") {
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content).map_err(|e| {
|
||||
InstallError::InvalidManifest(format!("无法读取 plugin.json: {}", e))
|
||||
})?;
|
||||
return Ok(content);
|
||||
}
|
||||
}
|
||||
|
||||
Err(InstallError::InvalidPackage(
|
||||
"压缩包中未找到 plugin.json".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 从 tar.gz 中提取 plugin.json
|
||||
fn extract_manifest_from_targz(&self, path: &Path) -> Result<String, InstallError> {
|
||||
let file = File::open(path)?;
|
||||
let gz = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
|
||||
for entry in archive
|
||||
.entries()
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("无法读取 tar.gz 文件: {}", e)))?
|
||||
{
|
||||
let mut entry = entry
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("tar.gz 条目读取失败: {}", e)))?;
|
||||
|
||||
let entry_path = entry
|
||||
.path()
|
||||
.map_err(|e| InstallError::InvalidPackage(format!("tar.gz 条目路径无效: {}", e)))?;
|
||||
|
||||
if entry_path.ends_with("plugin.json") {
|
||||
let mut content = String::new();
|
||||
entry.read_to_string(&mut content).map_err(|e| {
|
||||
InstallError::InvalidManifest(format!("无法读取 plugin.json: {}", e))
|
||||
})?;
|
||||
return Ok(content);
|
||||
}
|
||||
}
|
||||
|
||||
Err(InstallError::InvalidPackage(
|
||||
"压缩包中未找到 plugin.json".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PackageValidator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugin::PluginType;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_manifest(name: &str, version: &str) -> PluginManifest {
|
||||
PluginManifest {
|
||||
name: name.to_string(),
|
||||
version: version.to_string(),
|
||||
description: "Test plugin".to_string(),
|
||||
author: None,
|
||||
homepage: None,
|
||||
license: None,
|
||||
entry: "config.json".to_string(),
|
||||
plugin_type: PluginType::Script,
|
||||
config_schema: None,
|
||||
hooks: vec![],
|
||||
min_proxycast_version: None,
|
||||
binary: None,
|
||||
ui: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_valid() {
|
||||
let validator = PackageValidator::new();
|
||||
let manifest = create_test_manifest("test-plugin", "1.0.0");
|
||||
assert!(validator.validate_manifest(&manifest).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_empty_name() {
|
||||
let validator = PackageValidator::new();
|
||||
let manifest = create_test_manifest("", "1.0.0");
|
||||
let result = validator.validate_manifest(&manifest);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("名称"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_empty_version() {
|
||||
let validator = PackageValidator::new();
|
||||
let manifest = create_test_manifest("test-plugin", "");
|
||||
let result = validator.validate_manifest(&manifest);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("版本"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_invalid_name() {
|
||||
let validator = PackageValidator::new();
|
||||
let manifest = create_test_manifest("test plugin!", "1.0.0");
|
||||
let result = validator.validate_manifest(&manifest);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_invalid_version() {
|
||||
let validator = PackageValidator::new();
|
||||
let manifest = create_test_manifest("test-plugin", "invalid");
|
||||
let result = validator.validate_manifest(&manifest);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_valid_versions() {
|
||||
let validator = PackageValidator::new();
|
||||
|
||||
// 有效版本格式
|
||||
let valid_versions = ["1.0", "1.0.0", "0.1.0", "10.20.30", "1.0.0-beta"];
|
||||
|
||||
for version in valid_versions {
|
||||
let manifest = create_test_manifest("test-plugin", version);
|
||||
assert!(
|
||||
validator.validate_manifest(&manifest).is_ok(),
|
||||
"Version {} should be valid",
|
||||
version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_name_too_long() {
|
||||
let validator = PackageValidator::new();
|
||||
let long_name = "a".repeat(65);
|
||||
let manifest = create_test_manifest(&long_name, "1.0.0");
|
||||
let result = validator.validate_manifest(&manifest);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_empty_entry() {
|
||||
let validator = PackageValidator::new();
|
||||
let mut manifest = create_test_manifest("test-plugin", "1.0.0");
|
||||
manifest.entry = "".to_string();
|
||||
let result = validator.validate_manifest(&manifest);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("入口"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_manifest_invalid_hook() {
|
||||
let validator = PackageValidator::new();
|
||||
let mut manifest = create_test_manifest("test-plugin", "1.0.0");
|
||||
manifest.hooks = vec!["valid_hook".to_string(), "invalid hook!".to_string()];
|
||||
let result = validator.validate_manifest(&manifest);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("钩子"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_name() {
|
||||
assert!(PackageValidator::is_valid_name("test-plugin"));
|
||||
assert!(PackageValidator::is_valid_name("test_plugin"));
|
||||
assert!(PackageValidator::is_valid_name("TestPlugin123"));
|
||||
assert!(!PackageValidator::is_valid_name(""));
|
||||
assert!(!PackageValidator::is_valid_name("test plugin"));
|
||||
assert!(!PackageValidator::is_valid_name("test.plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_version() {
|
||||
assert!(PackageValidator::is_valid_version("1.0"));
|
||||
assert!(PackageValidator::is_valid_version("1.0.0"));
|
||||
assert!(PackageValidator::is_valid_version("1.0.0-beta"));
|
||||
assert!(PackageValidator::is_valid_version("0.1.0"));
|
||||
assert!(!PackageValidator::is_valid_version(""));
|
||||
assert!(!PackageValidator::is_valid_version("invalid"));
|
||||
assert!(!PackageValidator::is_valid_version("1"));
|
||||
assert!(!PackageValidator::is_valid_version("1.0.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_format_nonexistent_file() {
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_format(Path::new("/nonexistent/file.zip"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("不存在"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_format_unsupported_extension() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "test content").unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_format(&file_path);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("不支持"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_format_invalid_zip_magic() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.zip");
|
||||
std::fs::write(&file_path, "not a zip file").unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_format(&file_path);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("无效的 ZIP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_format_invalid_targz_magic() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.tar.gz");
|
||||
std::fs::write(&file_path, "not a tar.gz file").unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_format(&file_path);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("无效的 tar.gz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_format_empty_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("empty.zip");
|
||||
std::fs::write(&file_path, "").unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_format(&file_path);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("为空"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_format_valid_zip() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.zip");
|
||||
|
||||
// 创建有效的 ZIP 文件
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::FileOptions::default();
|
||||
zip.start_file("test.txt", options).unwrap();
|
||||
zip.write_all(b"test content").unwrap();
|
||||
zip.finish().unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_format(&file_path);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), PackageFormat::Zip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_format_valid_targz() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.tar.gz");
|
||||
|
||||
// 创建有效的 tar.gz 文件
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
let mut tar = tar::Builder::new(gz);
|
||||
|
||||
let content = b"test content";
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_path("test.txt").unwrap();
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append(&header, &content[..]).unwrap();
|
||||
tar.into_inner().unwrap().finish().unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_format(&file_path);
|
||||
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
|
||||
assert_eq!(result.unwrap(), PackageFormat::TarGz);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_manifest_from_zip() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("plugin.zip");
|
||||
|
||||
// 创建包含 plugin.json 的 ZIP 文件
|
||||
let manifest_json = r#"{
|
||||
"name": "test-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Test plugin",
|
||||
"entry": "config.json",
|
||||
"plugin_type": "script",
|
||||
"hooks": []
|
||||
}"#;
|
||||
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::FileOptions::default();
|
||||
zip.start_file("plugin.json", options).unwrap();
|
||||
zip.write_all(manifest_json.as_bytes()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.extract_and_validate_manifest(&file_path, PackageFormat::Zip);
|
||||
assert!(result.is_ok());
|
||||
let manifest = result.unwrap();
|
||||
assert_eq!(manifest.name, "test-plugin");
|
||||
assert_eq!(manifest.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_manifest_from_targz() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("plugin.tar.gz");
|
||||
|
||||
// 创建包含 plugin.json 的 tar.gz 文件
|
||||
let manifest_json = r#"{
|
||||
"name": "test-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Test plugin",
|
||||
"entry": "config.json",
|
||||
"plugin_type": "script",
|
||||
"hooks": []
|
||||
}"#;
|
||||
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
let mut tar = tar::Builder::new(gz);
|
||||
|
||||
let content = manifest_json.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_path("plugin.json").unwrap();
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append(&header, content).unwrap();
|
||||
tar.into_inner().unwrap().finish().unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.extract_and_validate_manifest(&file_path, PackageFormat::TarGz);
|
||||
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
|
||||
let manifest = result.unwrap();
|
||||
assert_eq!(manifest.name, "test-plugin");
|
||||
assert_eq!(manifest.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_manifest_missing() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("no-manifest.zip");
|
||||
|
||||
// 创建不包含 plugin.json 的 ZIP 文件
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::FileOptions::default();
|
||||
zip.start_file("other.txt", options).unwrap();
|
||||
zip.write_all(b"other content").unwrap();
|
||||
zip.finish().unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.extract_and_validate_manifest(&file_path, PackageFormat::Zip);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("plugin.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_integrity_no_checksum() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "test content").unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_integrity(&file_path, None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_integrity_valid_checksum() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "test content").unwrap();
|
||||
|
||||
// SHA256 of "test content"
|
||||
let checksum = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72";
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_integrity(&file_path, Some(checksum));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_integrity_invalid_checksum() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "test content").unwrap();
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_integrity(&file_path, Some("invalid_checksum"));
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
InstallError::ChecksumMismatch {
|
||||
expected,
|
||||
actual: _,
|
||||
} => {
|
||||
assert_eq!(expected, "invalid_checksum");
|
||||
}
|
||||
_ => panic!("Expected ChecksumMismatch error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod property_tests {
|
||||
use super::*;
|
||||
use crate::plugin::PluginType;
|
||||
use proptest::prelude::*;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 生成有效的插件名称
|
||||
fn arb_valid_plugin_name() -> impl Strategy<Value = String> {
|
||||
"[a-zA-Z][a-zA-Z0-9_-]{0,30}".prop_map(|s| s)
|
||||
}
|
||||
|
||||
/// 生成有效的版本号
|
||||
fn arb_valid_version() -> impl Strategy<Value = String> {
|
||||
(1u32..100, 0u32..100, 0u32..100)
|
||||
.prop_map(|(major, minor, patch)| format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
/// 生成有效的插件类型
|
||||
fn arb_plugin_type() -> impl Strategy<Value = &'static str> {
|
||||
prop_oneof![Just("script"), Just("native"), Just("binary"),]
|
||||
}
|
||||
|
||||
/// 生成有效的钩子名称
|
||||
fn arb_valid_hook() -> impl Strategy<Value = String> {
|
||||
"[a-zA-Z][a-zA-Z0-9_:]{0,20}".prop_map(|s| s)
|
||||
}
|
||||
|
||||
/// 生成有效的 PluginManifest JSON
|
||||
fn arb_valid_manifest_json() -> impl Strategy<Value = String> {
|
||||
(
|
||||
arb_valid_plugin_name(),
|
||||
arb_valid_version(),
|
||||
arb_plugin_type(),
|
||||
prop::collection::vec(arb_valid_hook(), 0..3),
|
||||
any::<Option<String>>().prop_map(|opt| opt.map(|_| "Test description".to_string())),
|
||||
)
|
||||
.prop_map(|(name, version, plugin_type, hooks, description)| {
|
||||
let hooks_json = hooks
|
||||
.iter()
|
||||
.map(|h| format!("\"{}\"", h))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let desc = description.unwrap_or_else(|| "Test plugin".to_string());
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "{}",
|
||||
"version": "{}",
|
||||
"description": "{}",
|
||||
"entry": "config.json",
|
||||
"plugin_type": "{}",
|
||||
"hooks": [{}]
|
||||
}}"#,
|
||||
name, version, desc, plugin_type, hooks_json
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 创建包含指定 manifest 的 ZIP 文件
|
||||
fn create_zip_with_manifest(temp_dir: &TempDir, manifest_json: &str) -> std::path::PathBuf {
|
||||
let file_path = temp_dir.path().join("plugin.zip");
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::FileOptions::default();
|
||||
zip.start_file("plugin.json", options).unwrap();
|
||||
zip.write_all(manifest_json.as_bytes()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
file_path
|
||||
}
|
||||
|
||||
/// 创建包含指定 manifest 的 tar.gz 文件
|
||||
fn create_targz_with_manifest(temp_dir: &TempDir, manifest_json: &str) -> std::path::PathBuf {
|
||||
let file_path = temp_dir.path().join("plugin.tar.gz");
|
||||
let file = File::create(&file_path).unwrap();
|
||||
let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
let mut tar = tar::Builder::new(gz);
|
||||
|
||||
let content = manifest_json.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_path("plugin.json").unwrap();
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append(&header, content).unwrap();
|
||||
tar.into_inner().unwrap().finish().unwrap();
|
||||
file_path
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(100))]
|
||||
|
||||
/// Feature: plugin-installation, 属性 1: 包验证完整性
|
||||
///
|
||||
/// *对于任意*插件包文件,如果包通过验证,则它必须包含有效的 plugin.json 清单,
|
||||
/// 且包含所有必需字段(name、version、plugin_type)。
|
||||
///
|
||||
/// **验证需求: 1.1, 5.1, 5.4**
|
||||
#[test]
|
||||
fn prop_valid_zip_package_contains_valid_manifest(manifest_json in arb_valid_manifest_json()) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = create_zip_with_manifest(&temp_dir, &manifest_json);
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
|
||||
// 验证包格式
|
||||
let format_result = validator.validate_format(&file_path);
|
||||
prop_assert!(format_result.is_ok(), "Format validation failed: {:?}", format_result);
|
||||
prop_assert_eq!(format_result.unwrap(), PackageFormat::Zip);
|
||||
|
||||
// 提取并验证清单
|
||||
let manifest_result = validator.extract_and_validate_manifest(&file_path, PackageFormat::Zip);
|
||||
prop_assert!(manifest_result.is_ok(), "Manifest validation failed: {:?}", manifest_result);
|
||||
|
||||
let manifest = manifest_result.unwrap();
|
||||
|
||||
// 验证必需字段存在且非空
|
||||
prop_assert!(!manifest.name.is_empty(), "Name should not be empty");
|
||||
prop_assert!(!manifest.version.is_empty(), "Version should not be empty");
|
||||
prop_assert!(!manifest.entry.is_empty(), "Entry should not be empty");
|
||||
|
||||
// 验证名称格式
|
||||
prop_assert!(
|
||||
PackageValidator::is_valid_name(&manifest.name),
|
||||
"Name should be valid: {}",
|
||||
manifest.name
|
||||
);
|
||||
|
||||
// 验证版本格式
|
||||
prop_assert!(
|
||||
PackageValidator::is_valid_version(&manifest.version),
|
||||
"Version should be valid: {}",
|
||||
manifest.version
|
||||
);
|
||||
}
|
||||
|
||||
/// Feature: plugin-installation, 属性 1: 包验证完整性 (tar.gz)
|
||||
///
|
||||
/// **验证需求: 1.1, 5.1, 5.4**
|
||||
#[test]
|
||||
fn prop_valid_targz_package_contains_valid_manifest(manifest_json in arb_valid_manifest_json()) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = create_targz_with_manifest(&temp_dir, &manifest_json);
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
|
||||
// 验证包格式
|
||||
let format_result = validator.validate_format(&file_path);
|
||||
prop_assert!(format_result.is_ok(), "Format validation failed: {:?}", format_result);
|
||||
prop_assert_eq!(format_result.unwrap(), PackageFormat::TarGz);
|
||||
|
||||
// 提取并验证清单
|
||||
let manifest_result = validator.extract_and_validate_manifest(&file_path, PackageFormat::TarGz);
|
||||
prop_assert!(manifest_result.is_ok(), "Manifest validation failed: {:?}", manifest_result);
|
||||
|
||||
let manifest = manifest_result.unwrap();
|
||||
|
||||
// 验证必需字段存在且非空
|
||||
prop_assert!(!manifest.name.is_empty(), "Name should not be empty");
|
||||
prop_assert!(!manifest.version.is_empty(), "Version should not be empty");
|
||||
prop_assert!(!manifest.entry.is_empty(), "Entry should not be empty");
|
||||
|
||||
// 验证名称格式
|
||||
prop_assert!(
|
||||
PackageValidator::is_valid_name(&manifest.name),
|
||||
"Name should be valid: {}",
|
||||
manifest.name
|
||||
);
|
||||
|
||||
// 验证版本格式
|
||||
prop_assert!(
|
||||
PackageValidator::is_valid_version(&manifest.version),
|
||||
"Version should be valid: {}",
|
||||
manifest.version
|
||||
);
|
||||
}
|
||||
|
||||
/// Feature: plugin-installation, 属性 1: 包验证完整性 (反向测试)
|
||||
///
|
||||
/// *对于任意*无效的清单(缺少必需字段),验证应该失败。
|
||||
///
|
||||
/// **验证需求: 5.4**
|
||||
#[test]
|
||||
fn prop_invalid_manifest_fails_validation(
|
||||
name in prop::option::of(arb_valid_plugin_name()),
|
||||
version in prop::option::of(arb_valid_version()),
|
||||
) {
|
||||
// 只有当 name 或 version 缺失时才测试
|
||||
prop_assume!(name.is_none() || version.is_none());
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: name.unwrap_or_default(),
|
||||
version: version.unwrap_or_default(),
|
||||
description: "Test".to_string(),
|
||||
author: None,
|
||||
homepage: None,
|
||||
license: None,
|
||||
entry: "config.json".to_string(),
|
||||
plugin_type: PluginType::Script,
|
||||
config_schema: None,
|
||||
hooks: vec![],
|
||||
min_proxycast_version: None,
|
||||
binary: None,
|
||||
ui: None,
|
||||
};
|
||||
|
||||
let validator = PackageValidator::new();
|
||||
let result = validator.validate_manifest(&manifest);
|
||||
|
||||
// 缺少必需字段时验证应该失败
|
||||
prop_assert!(result.is_err(), "Validation should fail for invalid manifest");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,6 +435,50 @@ impl PluginManager {
|
||||
.map(|r| (r.key().clone(), r.value().clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 插件 UI 相关方法
|
||||
// ========================================================================
|
||||
|
||||
/// 获取插件的 Surface 定义
|
||||
pub async fn get_plugin_surfaces(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
) -> Result<Vec<super::SurfaceDefinition>, PluginError> {
|
||||
// 目前返回空列表,后续可以扩展为从插件获取 UI 定义
|
||||
// 插件需要实现 PluginUI trait
|
||||
let _instance = self
|
||||
.plugins
|
||||
.get(plugin_id)
|
||||
.ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?;
|
||||
|
||||
// TODO: 检查插件是否实现了 PluginUI trait
|
||||
// 目前返回空列表
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// 处理插件 UI 操作
|
||||
pub async fn handle_plugin_action(
|
||||
&mut self,
|
||||
plugin_id: &str,
|
||||
action: super::UserAction,
|
||||
) -> Result<Vec<super::UIMessage>, PluginError> {
|
||||
let _instance = self
|
||||
.plugins
|
||||
.get(plugin_id)
|
||||
.ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?;
|
||||
|
||||
// TODO: 将操作转发给插件的 handle_action 方法
|
||||
// 目前返回空列表
|
||||
tracing::debug!(
|
||||
"收到插件 {} 的 UI 操作: {} (surface: {})",
|
||||
plugin_id,
|
||||
action.name,
|
||||
action.surface_id
|
||||
);
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginManager {
|
||||
|
||||
@@ -6,11 +6,19 @@
|
||||
//! - 插件隔离和错误处理
|
||||
//! - 插件配置管理
|
||||
//! - 二进制组件下载和管理
|
||||
//! - 声明式插件 UI 系统
|
||||
//! - 插件安装和卸载
|
||||
|
||||
pub mod binary_downloader;
|
||||
pub mod examples;
|
||||
pub mod installer;
|
||||
mod loader;
|
||||
mod manager;
|
||||
mod types;
|
||||
pub mod ui_builder;
|
||||
pub mod ui_events;
|
||||
pub mod ui_trait;
|
||||
pub mod ui_types;
|
||||
|
||||
pub use binary_downloader::BinaryDownloader;
|
||||
pub use loader::PluginLoader;
|
||||
@@ -19,6 +27,12 @@ pub use types::{
|
||||
BinaryComponentStatus, BinaryManifest, HookResult, PlatformBinaries, Plugin, PluginConfig,
|
||||
PluginContext, PluginError, PluginInfo, PluginManifest, PluginState, PluginStatus, PluginType,
|
||||
};
|
||||
pub use ui_events::{PluginUIEmitter, PluginUIEmitterState, PluginUIEventPayload};
|
||||
pub use ui_trait::{NoUI, PluginUI};
|
||||
pub use ui_types::{
|
||||
Action, BoundValue, ChildrenDef, ComponentDef, ComponentType, DataEntry, DataModelUpdate,
|
||||
SurfaceDefinition, SurfaceUpdate, UIMessage, UserAction,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -18,6 +18,8 @@ fn test_plugin_manifest_validation() {
|
||||
config_schema: None,
|
||||
hooks: vec!["on_request".to_string()],
|
||||
min_proxycast_version: None,
|
||||
binary: None,
|
||||
ui: None,
|
||||
};
|
||||
assert!(valid.validate().is_ok());
|
||||
|
||||
@@ -154,6 +156,8 @@ fn test_plugin_manifest_serde() {
|
||||
})),
|
||||
hooks: vec!["on_request".to_string(), "on_response".to_string()],
|
||||
min_proxycast_version: Some("0.13.0".to_string()),
|
||||
binary: None,
|
||||
ui: None,
|
||||
};
|
||||
|
||||
// 序列化
|
||||
|
||||
@@ -79,8 +79,11 @@ impl fmt::Display for PluginStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件清单 (manifest.json)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// 插件清单 (manifest.json / plugin.json)
|
||||
///
|
||||
/// 描述插件的元数据、依赖和入口点
|
||||
/// _需求: 5.1, 5.2, 5.3_
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PluginManifest {
|
||||
/// 插件名称
|
||||
pub name: String,
|
||||
@@ -113,6 +116,14 @@ pub struct PluginManifest {
|
||||
/// 最低 ProxyCast 版本要求
|
||||
#[serde(default)]
|
||||
pub min_proxycast_version: Option<String>,
|
||||
/// Binary 类型插件的扩展配置
|
||||
/// _需求: 5.2_
|
||||
#[serde(default)]
|
||||
pub binary: Option<BinaryManifest>,
|
||||
/// UI 配置
|
||||
/// _需求: 5.3_
|
||||
#[serde(default)]
|
||||
pub ui: Option<UiManifest>,
|
||||
}
|
||||
|
||||
fn default_entry() -> String {
|
||||
@@ -147,7 +158,7 @@ pub enum PluginType {
|
||||
}
|
||||
|
||||
/// 平台二进制文件名映射
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PlatformBinaries {
|
||||
/// macOS ARM64 (Apple Silicon)
|
||||
#[serde(rename = "macos-arm64")]
|
||||
@@ -181,7 +192,7 @@ impl PlatformBinaries {
|
||||
}
|
||||
|
||||
/// Binary 类型的 manifest 扩展字段
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BinaryManifest {
|
||||
/// 二进制文件名(不含平台后缀)
|
||||
pub binary_name: String,
|
||||
@@ -196,6 +207,29 @@ pub struct BinaryManifest {
|
||||
pub checksum_file: Option<String>,
|
||||
}
|
||||
|
||||
/// UI 配置扩展字段
|
||||
///
|
||||
/// 定义插件的 UI 展示配置
|
||||
/// _需求: 5.2, 5.3_
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct UiManifest {
|
||||
/// UI 展示位置 (如 "main", "settings", "sidebar")
|
||||
#[serde(default)]
|
||||
pub surfaces: Vec<String>,
|
||||
/// 图标名称 (使用 Lucide 图标名)
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// 窗口标题
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
/// 窗口默认宽度
|
||||
#[serde(default)]
|
||||
pub default_width: Option<u32>,
|
||||
/// 窗口默认高度
|
||||
#[serde(default)]
|
||||
pub default_height: Option<u32>,
|
||||
}
|
||||
|
||||
/// 二进制组件状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BinaryComponentStatus {
|
||||
@@ -499,3 +533,225 @@ impl PluginInstance {
|
||||
self.config.enabled && self.state.status == PluginStatus::Enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// 生成随机的 PlatformBinaries
|
||||
fn arb_platform_binaries() -> impl Strategy<Value = PlatformBinaries> {
|
||||
(
|
||||
"[a-z0-9_-]{1,30}",
|
||||
"[a-z0-9_-]{1,30}",
|
||||
"[a-z0-9_-]{1,30}",
|
||||
"[a-z0-9_-]{1,30}",
|
||||
"[a-z0-9_-]{1,30}",
|
||||
)
|
||||
.prop_map(
|
||||
|(macos_arm64, macos_x64, linux_x64, linux_arm64, windows_x64)| PlatformBinaries {
|
||||
macos_arm64,
|
||||
macos_x64,
|
||||
linux_x64,
|
||||
linux_arm64,
|
||||
windows_x64,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成随机的 BinaryManifest
|
||||
fn arb_binary_manifest() -> impl Strategy<Value = BinaryManifest> {
|
||||
(
|
||||
"[a-z0-9_-]{1,20}",
|
||||
"[a-z0-9_-]{1,20}",
|
||||
"[a-z0-9_-]{1,20}",
|
||||
arb_platform_binaries(),
|
||||
proptest::option::of("[a-z0-9_-]{1,20}"),
|
||||
)
|
||||
.prop_map(
|
||||
|(binary_name, github_owner, github_repo, platform_binaries, checksum_file)| {
|
||||
BinaryManifest {
|
||||
binary_name,
|
||||
github_owner,
|
||||
github_repo,
|
||||
platform_binaries,
|
||||
checksum_file,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成随机的 UiManifest
|
||||
fn arb_ui_manifest() -> impl Strategy<Value = UiManifest> {
|
||||
(
|
||||
prop::collection::vec("[a-z]{1,10}", 0..3),
|
||||
proptest::option::of("[a-z-]{1,20}"),
|
||||
proptest::option::of("[a-zA-Z0-9 ]{1,30}"),
|
||||
proptest::option::of(100u32..2000u32),
|
||||
proptest::option::of(100u32..2000u32),
|
||||
)
|
||||
.prop_map(|(surfaces, icon, title, default_width, default_height)| {
|
||||
UiManifest {
|
||||
surfaces,
|
||||
icon,
|
||||
title,
|
||||
default_width,
|
||||
default_height,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成随机的 PluginType
|
||||
fn arb_plugin_type() -> impl Strategy<Value = PluginType> {
|
||||
prop_oneof![
|
||||
Just(PluginType::Script),
|
||||
Just(PluginType::Native),
|
||||
Just(PluginType::Binary),
|
||||
]
|
||||
}
|
||||
|
||||
/// 生成随机的 PluginManifest
|
||||
///
|
||||
/// 用于属性测试,生成包含所有字段的完整清单
|
||||
fn arb_plugin_manifest() -> impl Strategy<Value = PluginManifest> {
|
||||
(
|
||||
"[a-z0-9_-]{1,20}", // name
|
||||
"[0-9]{1,2}\\.[0-9]{1,2}\\.[0-9]{1,2}", // version
|
||||
"[a-zA-Z0-9 ]{0,50}", // description
|
||||
proptest::option::of("[a-zA-Z ]{1,30}"), // author
|
||||
proptest::option::of("https://[a-z]{1,20}\\.com"), // homepage
|
||||
proptest::option::of("[A-Z]{2,5}"), // license
|
||||
"[a-z0-9_-]{1,20}", // entry
|
||||
arb_plugin_type(), // plugin_type
|
||||
prop::collection::vec("[a-z_]{1,15}", 0..5), // hooks
|
||||
proptest::option::of("[0-9]{1,2}\\.[0-9]{1,2}\\.[0-9]{1,2}"), // min_proxycast_version
|
||||
proptest::option::of(arb_binary_manifest()), // binary
|
||||
proptest::option::of(arb_ui_manifest()), // ui
|
||||
)
|
||||
.prop_map(
|
||||
|(
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
author,
|
||||
homepage,
|
||||
license,
|
||||
entry,
|
||||
plugin_type,
|
||||
hooks,
|
||||
min_proxycast_version,
|
||||
binary,
|
||||
ui,
|
||||
)| {
|
||||
PluginManifest {
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
author,
|
||||
homepage,
|
||||
license,
|
||||
entry,
|
||||
plugin_type,
|
||||
config_schema: None, // JSON Schema 太复杂,跳过
|
||||
hooks,
|
||||
min_proxycast_version,
|
||||
binary,
|
||||
ui,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(20))]
|
||||
|
||||
/// **Feature: plugin-installation, 属性 5: 清单 Round-Trip**
|
||||
/// **验证需求: 5.1, 5.2, 5.3**
|
||||
///
|
||||
/// *对于任意*有效的 PluginManifest 对象,序列化为 JSON 然后反序列化必须产生等价的对象。
|
||||
#[test]
|
||||
fn manifest_roundtrip(manifest in arb_plugin_manifest()) {
|
||||
// 序列化为 JSON
|
||||
let json = serde_json::to_string(&manifest).expect("序列化应该成功");
|
||||
|
||||
// 反序列化回 PluginManifest
|
||||
let parsed: PluginManifest = serde_json::from_str(&json).expect("反序列化应该成功");
|
||||
|
||||
// 验证整体相等
|
||||
prop_assert_eq!(manifest, parsed, "整个 PluginManifest 应该相等");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_manifest_serialization() {
|
||||
let ui = UiManifest {
|
||||
surfaces: vec!["main".to_string(), "settings".to_string()],
|
||||
icon: Some("puzzle".to_string()),
|
||||
title: Some("Test Plugin".to_string()),
|
||||
default_width: Some(800),
|
||||
default_height: Some(600),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&ui).unwrap();
|
||||
assert!(json.contains("main"));
|
||||
assert!(json.contains("puzzle"));
|
||||
|
||||
let parsed: UiManifest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.surfaces, ui.surfaces);
|
||||
assert_eq!(parsed.icon, ui.icon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_manifest_with_binary_and_ui() {
|
||||
let manifest = PluginManifest {
|
||||
name: "machine-id-tool".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
description: "Machine ID 管理工具".to_string(),
|
||||
author: Some("ProxyCast Team".to_string()),
|
||||
homepage: Some("https://github.com/user/machine-id-tool".to_string()),
|
||||
license: Some("MIT".to_string()),
|
||||
entry: "machine-id-tool".to_string(),
|
||||
plugin_type: PluginType::Binary,
|
||||
config_schema: None,
|
||||
hooks: vec![],
|
||||
min_proxycast_version: Some("1.0.0".to_string()),
|
||||
binary: Some(BinaryManifest {
|
||||
binary_name: "machine-id-tool".to_string(),
|
||||
github_owner: "user".to_string(),
|
||||
github_repo: "machine-id-tool".to_string(),
|
||||
platform_binaries: PlatformBinaries {
|
||||
macos_arm64: "machine-id-tool-aarch64-apple-darwin".to_string(),
|
||||
macos_x64: "machine-id-tool-x86_64-apple-darwin".to_string(),
|
||||
linux_x64: "machine-id-tool-x86_64-unknown-linux-gnu".to_string(),
|
||||
linux_arm64: "machine-id-tool-aarch64-unknown-linux-gnu".to_string(),
|
||||
windows_x64: "machine-id-tool-x86_64-pc-windows-msvc.exe".to_string(),
|
||||
},
|
||||
checksum_file: None,
|
||||
}),
|
||||
ui: Some(UiManifest {
|
||||
surfaces: vec!["main".to_string()],
|
||||
icon: Some("puzzle".to_string()),
|
||||
title: None,
|
||||
default_width: None,
|
||||
default_height: None,
|
||||
}),
|
||||
};
|
||||
|
||||
// 序列化
|
||||
let json = serde_json::to_string_pretty(&manifest).unwrap();
|
||||
assert!(json.contains("machine-id-tool"));
|
||||
assert!(json.contains("binary"));
|
||||
assert!(json.contains("platform_binaries"));
|
||||
assert!(json.contains("macos-arm64"));
|
||||
|
||||
// 反序列化
|
||||
let parsed: PluginManifest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, manifest.name);
|
||||
assert!(parsed.binary.is_some());
|
||||
assert!(parsed.ui.is_some());
|
||||
|
||||
let binary = parsed.binary.unwrap();
|
||||
assert_eq!(binary.binary_name, "machine-id-tool");
|
||||
assert_eq!(binary.github_owner, "user");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! 插件 UI 构建器
|
||||
//!
|
||||
//! 提供便捷的 API 来构建插件 UI
|
||||
|
||||
use super::ui_types::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// Surface 构建器
|
||||
pub struct SurfaceBuilder {
|
||||
surface_id: String,
|
||||
root_id: String,
|
||||
components: Vec<ComponentDef>,
|
||||
data: serde_json::Value,
|
||||
styles: Option<SurfaceStyles>,
|
||||
}
|
||||
|
||||
impl SurfaceBuilder {
|
||||
/// 创建新的 Surface 构建器
|
||||
pub fn new(surface_id: impl Into<String>, root_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
surface_id: surface_id.into(),
|
||||
root_id: root_id.into(),
|
||||
components: Vec::new(),
|
||||
data: json!({}),
|
||||
styles: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加组件
|
||||
pub fn component(mut self, def: ComponentDef) -> Self {
|
||||
self.components.push(def);
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加多个组件
|
||||
pub fn components(mut self, defs: Vec<ComponentDef>) -> Self {
|
||||
self.components.extend(defs);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置初始数据
|
||||
pub fn data(mut self, data: serde_json::Value) -> Self {
|
||||
self.data = data;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置样式
|
||||
pub fn styles(mut self, styles: SurfaceStyles) -> Self {
|
||||
self.styles = Some(styles);
|
||||
self
|
||||
}
|
||||
|
||||
/// 构建 SurfaceDefinition
|
||||
pub fn build(self) -> SurfaceDefinition {
|
||||
SurfaceDefinition {
|
||||
surface_id: self.surface_id,
|
||||
root_id: self.root_id,
|
||||
initial_components: self.components,
|
||||
initial_data: self.data,
|
||||
styles: self.styles,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 组件构建宏辅助
|
||||
impl ComponentDef {
|
||||
/// 创建 Row 组件
|
||||
pub fn row(id: impl Into<String>, children: ChildrenDef) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::Row(RowProps {
|
||||
children,
|
||||
distribution: None,
|
||||
alignment: None,
|
||||
gap: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 Column 组件
|
||||
pub fn column(id: impl Into<String>, children: ChildrenDef) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::Column(ColumnProps {
|
||||
children,
|
||||
distribution: None,
|
||||
alignment: None,
|
||||
gap: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 Card 组件
|
||||
pub fn card(id: impl Into<String>, child: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::Card(CardProps {
|
||||
child: child.into(),
|
||||
title: None,
|
||||
description: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 Text 组件
|
||||
pub fn text(id: impl Into<String>, text: BoundValue) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::Text(TextProps {
|
||||
text,
|
||||
variant: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 Text 组件(字面值)
|
||||
pub fn text_literal(id: impl Into<String>, text: impl Into<String>) -> Self {
|
||||
Self::text(id, BoundValue::string(text))
|
||||
}
|
||||
|
||||
/// 创建 Text 组件(路径绑定)
|
||||
pub fn text_bound(id: impl Into<String>, path: impl Into<String>) -> Self {
|
||||
Self::text(id, BoundValue::path(path))
|
||||
}
|
||||
|
||||
/// 创建 Icon 组件
|
||||
pub fn icon(id: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::Icon(IconProps {
|
||||
name: BoundValue::string(name),
|
||||
size: None,
|
||||
color: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 Button 组件
|
||||
pub fn button(id: impl Into<String>, child: impl Into<String>, action: Action) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::Button(ButtonProps {
|
||||
child: child.into(),
|
||||
action,
|
||||
variant: None,
|
||||
disabled: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 Badge 组件
|
||||
pub fn badge(id: impl Into<String>, text: BoundValue) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::Badge(BadgeProps {
|
||||
text,
|
||||
variant: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 List 组件
|
||||
pub fn list(id: impl Into<String>, children: ChildrenDef) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::List(ListProps {
|
||||
children,
|
||||
direction: None,
|
||||
alignment: None,
|
||||
gap: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 Alert 组件
|
||||
pub fn alert(id: impl Into<String>, message: BoundValue, alert_type: AlertType) -> Self {
|
||||
Self::new(
|
||||
id,
|
||||
ComponentType::Alert(AlertProps {
|
||||
message,
|
||||
alert_type,
|
||||
title: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 Spinner 组件
|
||||
pub fn spinner(id: impl Into<String>) -> Self {
|
||||
Self::new(id, ComponentType::Spinner(SpinnerProps::default()))
|
||||
}
|
||||
|
||||
/// 创建 Empty 组件
|
||||
pub fn empty(id: impl Into<String>) -> Self {
|
||||
Self::new(id, ComponentType::Empty(EmptyProps::default()))
|
||||
}
|
||||
|
||||
/// 创建 Divider 组件
|
||||
pub fn divider(id: impl Into<String>) -> Self {
|
||||
Self::new(id, ComponentType::Divider(DividerProps::default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Row 属性构建器
|
||||
impl RowProps {
|
||||
pub fn with_distribution(mut self, distribution: Distribution) -> Self {
|
||||
self.distribution = Some(distribution);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_alignment(mut self, alignment: Alignment) -> Self {
|
||||
self.alignment = Some(alignment);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_gap(mut self, gap: u32) -> Self {
|
||||
self.gap = Some(gap);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Column 属性构建器
|
||||
impl ColumnProps {
|
||||
pub fn with_distribution(mut self, distribution: Distribution) -> Self {
|
||||
self.distribution = Some(distribution);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_alignment(mut self, alignment: Alignment) -> Self {
|
||||
self.alignment = Some(alignment);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_gap(mut self, gap: u32) -> Self {
|
||||
self.gap = Some(gap);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Card 属性构建器
|
||||
impl CardProps {
|
||||
pub fn with_title(mut self, title: BoundValue) -> Self {
|
||||
self.title = Some(title);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: BoundValue) -> Self {
|
||||
self.description = Some(description);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Text 属性构建器
|
||||
impl TextProps {
|
||||
pub fn with_variant(mut self, variant: TextVariant) -> Self {
|
||||
self.variant = Some(variant);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Button 属性构建器
|
||||
impl ButtonProps {
|
||||
pub fn with_variant(mut self, variant: ButtonVariant) -> Self {
|
||||
self.variant = Some(variant);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_disabled(mut self, disabled: BoundValue) -> Self {
|
||||
self.disabled = Some(disabled);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 插件 UI 事件系统
|
||||
//!
|
||||
//! 提供从 Rust 向前端推送 UI 更新的能力
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::ui_types::UIMessage;
|
||||
|
||||
/// 插件 UI 事件载荷
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginUIEventPayload {
|
||||
/// 插件 ID
|
||||
pub plugin_id: String,
|
||||
/// UI 消息
|
||||
pub message: UIMessage,
|
||||
}
|
||||
|
||||
/// 插件 UI 事件发射器
|
||||
pub struct PluginUIEmitter {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl PluginUIEmitter {
|
||||
/// 创建新的事件发射器
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
|
||||
/// 发送单个 UI 消息
|
||||
pub fn emit(&self, plugin_id: &str, message: UIMessage) -> Result<(), String> {
|
||||
let payload = PluginUIEventPayload {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
message,
|
||||
};
|
||||
|
||||
self.app_handle
|
||||
.emit("plugin-ui-message", payload)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 发送多个 UI 消息
|
||||
pub fn emit_all(&self, plugin_id: &str, messages: Vec<UIMessage>) -> Result<(), String> {
|
||||
for message in messages {
|
||||
self.emit(plugin_id, message)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送 Surface 更新
|
||||
pub fn emit_surface_update(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
update: super::ui_types::SurfaceUpdate,
|
||||
) -> Result<(), String> {
|
||||
self.emit(plugin_id, UIMessage::SurfaceUpdate(update))
|
||||
}
|
||||
|
||||
/// 发送数据模型更新
|
||||
pub fn emit_data_update(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
update: super::ui_types::DataModelUpdate,
|
||||
) -> Result<(), String> {
|
||||
self.emit(plugin_id, UIMessage::DataModelUpdate(update))
|
||||
}
|
||||
|
||||
/// 发送删除 Surface
|
||||
pub fn emit_delete_surface(&self, plugin_id: &str, surface_id: &str) -> Result<(), String> {
|
||||
self.emit(
|
||||
plugin_id,
|
||||
UIMessage::DeleteSurface(super::ui_types::DeleteSurface {
|
||||
surface_id: surface_id.to_string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局事件发射器状态
|
||||
pub struct PluginUIEmitterState(pub Option<PluginUIEmitter>);
|
||||
|
||||
impl PluginUIEmitterState {
|
||||
/// 创建空状态
|
||||
pub fn new() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
|
||||
/// 初始化发射器
|
||||
pub fn init(&mut self, app_handle: AppHandle) {
|
||||
self.0 = Some(PluginUIEmitter::new(app_handle));
|
||||
}
|
||||
|
||||
/// 获取发射器
|
||||
pub fn get(&self) -> Option<&PluginUIEmitter> {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginUIEmitterState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! 插件 UI Trait 定义
|
||||
//!
|
||||
//! 定义插件需要实现的 UI 接口
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::ui_types::{SurfaceDefinition, UIMessage, UserAction};
|
||||
use super::PluginError;
|
||||
|
||||
/// 插件 UI Trait
|
||||
/// 插件实现此 trait 以提供声明式 UI
|
||||
#[async_trait]
|
||||
pub trait PluginUI: Send + Sync {
|
||||
/// 获取插件的 Surface 定义列表
|
||||
/// 返回插件想要渲染的所有 UI Surface
|
||||
fn get_surfaces(&self) -> Vec<SurfaceDefinition>;
|
||||
|
||||
/// 处理用户操作
|
||||
/// 返回需要发送给前端的 UI 消息列表
|
||||
async fn handle_action(&mut self, action: UserAction) -> Result<Vec<UIMessage>, PluginError>;
|
||||
|
||||
/// 是否支持 UI
|
||||
fn has_ui(&self) -> bool {
|
||||
!self.get_surfaces().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 空 UI 实现 - 用于不需要 UI 的插件
|
||||
pub struct NoUI;
|
||||
|
||||
#[async_trait]
|
||||
impl PluginUI for NoUI {
|
||||
fn get_surfaces(&self) -> Vec<SurfaceDefinition> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn handle_action(&mut self, _action: UserAction) -> Result<Vec<UIMessage>, PluginError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
//! 插件 UI 类型定义
|
||||
//!
|
||||
//! 基于 A2UI 设计理念的声明式 UI 类型系统
|
||||
//! 插件通过这些类型声明 UI 结构,宿主应用负责渲染
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Surface ID
|
||||
pub type SurfaceId = String;
|
||||
|
||||
/// 组件 ID
|
||||
pub type ComponentId = String;
|
||||
|
||||
/// 数据路径 (JSONPath 格式)
|
||||
pub type DataPath = String;
|
||||
|
||||
// ============================================================================
|
||||
// 数据绑定
|
||||
// ============================================================================
|
||||
|
||||
/// 绑定值 - 支持字面值或数据路径绑定
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum BoundValue {
|
||||
/// 字符串字面值
|
||||
LiteralString { literal_string: String },
|
||||
/// 数字字面值
|
||||
LiteralNumber { literal_number: f64 },
|
||||
/// 布尔字面值
|
||||
LiteralBoolean { literal_boolean: bool },
|
||||
/// 路径绑定
|
||||
Path { path: DataPath },
|
||||
/// 字符串 + 路径(初始化)
|
||||
StringWithPath {
|
||||
literal_string: String,
|
||||
path: DataPath,
|
||||
},
|
||||
/// 数字 + 路径(初始化)
|
||||
NumberWithPath { literal_number: f64, path: DataPath },
|
||||
/// 布尔 + 路径(初始化)
|
||||
BooleanWithPath {
|
||||
literal_boolean: bool,
|
||||
path: DataPath,
|
||||
},
|
||||
}
|
||||
|
||||
impl BoundValue {
|
||||
/// 创建字符串字面值
|
||||
pub fn string(s: impl Into<String>) -> Self {
|
||||
BoundValue::LiteralString {
|
||||
literal_string: s.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建数字字面值
|
||||
pub fn number(n: f64) -> Self {
|
||||
BoundValue::LiteralNumber { literal_number: n }
|
||||
}
|
||||
|
||||
/// 创建布尔字面值
|
||||
pub fn boolean(b: bool) -> Self {
|
||||
BoundValue::LiteralBoolean { literal_boolean: b }
|
||||
}
|
||||
|
||||
/// 创建路径绑定
|
||||
pub fn path(p: impl Into<String>) -> Self {
|
||||
BoundValue::Path { path: p.into() }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 子组件定义
|
||||
// ============================================================================
|
||||
|
||||
/// 子组件列表定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChildrenDef {
|
||||
/// 显式列表 - 固定的子组件 ID 列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub explicit_list: Option<Vec<ComponentId>>,
|
||||
/// 模板 - 从数据列表动态生成子组件
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub template: Option<TemplateDef>,
|
||||
}
|
||||
|
||||
/// 模板定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TemplateDef {
|
||||
/// 模板组件 ID
|
||||
pub component_id: ComponentId,
|
||||
/// 数据绑定路径
|
||||
pub data_binding: DataPath,
|
||||
}
|
||||
|
||||
impl ChildrenDef {
|
||||
/// 创建显式列表
|
||||
pub fn explicit(ids: Vec<impl Into<String>>) -> Self {
|
||||
Self {
|
||||
explicit_list: Some(ids.into_iter().map(|s| s.into()).collect()),
|
||||
template: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建模板
|
||||
pub fn template(component_id: impl Into<String>, data_binding: impl Into<String>) -> Self {
|
||||
Self {
|
||||
explicit_list: None,
|
||||
template: Some(TemplateDef {
|
||||
component_id: component_id.into(),
|
||||
data_binding: data_binding.into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 操作定义
|
||||
// ============================================================================
|
||||
|
||||
/// 操作上下文项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionContextItem {
|
||||
pub key: String,
|
||||
pub value: BoundValue,
|
||||
}
|
||||
|
||||
/// 操作定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Action {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<Vec<ActionContextItem>>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// 创建简单操作
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加上下文
|
||||
pub fn with_context(mut self, key: impl Into<String>, value: BoundValue) -> Self {
|
||||
let item = ActionContextItem {
|
||||
key: key.into(),
|
||||
value,
|
||||
};
|
||||
self.context.get_or_insert_with(Vec::new).push(item);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件类型
|
||||
// ============================================================================
|
||||
|
||||
/// 文本变体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TextVariant {
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
H4,
|
||||
H5,
|
||||
#[default]
|
||||
Body,
|
||||
Caption,
|
||||
}
|
||||
|
||||
/// 按钮变体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ButtonVariant {
|
||||
#[default]
|
||||
Default,
|
||||
Primary,
|
||||
Secondary,
|
||||
Destructive,
|
||||
Outline,
|
||||
Ghost,
|
||||
}
|
||||
|
||||
/// Badge 变体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BadgeVariant {
|
||||
#[default]
|
||||
Default,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Info,
|
||||
}
|
||||
|
||||
/// Alert 类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AlertType {
|
||||
#[default]
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 对齐方式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Alignment {
|
||||
Start,
|
||||
#[default]
|
||||
Center,
|
||||
End,
|
||||
Stretch,
|
||||
}
|
||||
|
||||
/// 分布方式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Distribution {
|
||||
#[default]
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
SpaceBetween,
|
||||
SpaceAround,
|
||||
SpaceEvenly,
|
||||
}
|
||||
|
||||
/// 方向
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Direction {
|
||||
Horizontal,
|
||||
#[default]
|
||||
Vertical,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组件定义
|
||||
// ============================================================================
|
||||
|
||||
/// Row 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RowProps {
|
||||
pub children: ChildrenDef,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub distribution: Option<Distribution>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alignment: Option<Alignment>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gap: Option<u32>,
|
||||
}
|
||||
|
||||
/// Column 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ColumnProps {
|
||||
pub children: ChildrenDef,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub distribution: Option<Distribution>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alignment: Option<Alignment>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gap: Option<u32>,
|
||||
}
|
||||
|
||||
/// Card 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardProps {
|
||||
pub child: ComponentId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<BoundValue>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<BoundValue>,
|
||||
}
|
||||
|
||||
/// Text 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TextProps {
|
||||
pub text: BoundValue,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub variant: Option<TextVariant>,
|
||||
}
|
||||
|
||||
/// Icon 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IconProps {
|
||||
pub name: BoundValue,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// Button 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ButtonProps {
|
||||
pub child: ComponentId,
|
||||
pub action: Action,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub variant: Option<ButtonVariant>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled: Option<BoundValue>,
|
||||
}
|
||||
|
||||
/// Badge 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BadgeProps {
|
||||
pub text: BoundValue,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub variant: Option<BoundValue>,
|
||||
}
|
||||
|
||||
/// Progress 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProgressProps {
|
||||
pub value: BoundValue,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max: Option<f64>,
|
||||
}
|
||||
|
||||
/// TextField 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TextFieldProps {
|
||||
pub label: BoundValue,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<BoundValue>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub placeholder: Option<BoundValue>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled: Option<BoundValue>,
|
||||
}
|
||||
|
||||
/// Switch 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SwitchProps {
|
||||
pub label: BoundValue,
|
||||
pub checked: BoundValue,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled: Option<BoundValue>,
|
||||
}
|
||||
|
||||
/// List 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListProps {
|
||||
pub children: ChildrenDef,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<Direction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alignment: Option<Alignment>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gap: Option<u32>,
|
||||
}
|
||||
|
||||
/// Alert 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlertProps {
|
||||
pub message: BoundValue,
|
||||
#[serde(rename = "type")]
|
||||
pub alert_type: AlertType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<BoundValue>,
|
||||
}
|
||||
|
||||
/// Spinner 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SpinnerProps {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u32>,
|
||||
}
|
||||
|
||||
/// Empty 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct EmptyProps {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<BoundValue>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<BoundValue>,
|
||||
}
|
||||
|
||||
/// Divider 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DividerProps {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub axis: Option<String>,
|
||||
}
|
||||
|
||||
/// KeyValue 项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyValueItem {
|
||||
pub key: BoundValue,
|
||||
pub value: BoundValue,
|
||||
}
|
||||
|
||||
/// KeyValue 组件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyValueProps {
|
||||
pub items: Vec<KeyValueItem>,
|
||||
}
|
||||
|
||||
/// 组件类型枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ComponentType {
|
||||
Row(RowProps),
|
||||
Column(ColumnProps),
|
||||
Card(CardProps),
|
||||
Text(TextProps),
|
||||
Icon(IconProps),
|
||||
Button(ButtonProps),
|
||||
Badge(BadgeProps),
|
||||
Progress(ProgressProps),
|
||||
TextField(TextFieldProps),
|
||||
Switch(SwitchProps),
|
||||
List(ListProps),
|
||||
Alert(AlertProps),
|
||||
Spinner(SpinnerProps),
|
||||
Empty(EmptyProps),
|
||||
Divider(DividerProps),
|
||||
KeyValue(KeyValueProps),
|
||||
}
|
||||
|
||||
/// 组件定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComponentDef {
|
||||
/// 组件 ID
|
||||
pub id: ComponentId,
|
||||
/// 组件类型和属性
|
||||
pub component: ComponentType,
|
||||
/// flex-grow 权重
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub weight: Option<f64>,
|
||||
}
|
||||
|
||||
impl ComponentDef {
|
||||
/// 创建组件定义
|
||||
pub fn new(id: impl Into<String>, component: ComponentType) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
component,
|
||||
weight: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置权重
|
||||
pub fn with_weight(mut self, weight: f64) -> Self {
|
||||
self.weight = Some(weight);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息类型 (Server → Client)
|
||||
// ============================================================================
|
||||
|
||||
/// Surface 更新消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SurfaceUpdate {
|
||||
pub surface_id: SurfaceId,
|
||||
pub components: Vec<ComponentDef>,
|
||||
}
|
||||
|
||||
/// 数据条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataEntry {
|
||||
pub key: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_string: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_number: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_boolean: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_array: Option<Vec<DataEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_map: Option<Vec<DataEntry>>,
|
||||
}
|
||||
|
||||
impl DataEntry {
|
||||
/// 创建字符串条目
|
||||
pub fn string(key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
value_string: Some(value.into()),
|
||||
value_number: None,
|
||||
value_boolean: None,
|
||||
value_array: None,
|
||||
value_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建数字条目
|
||||
pub fn number(key: impl Into<String>, value: f64) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
value_string: None,
|
||||
value_number: Some(value),
|
||||
value_boolean: None,
|
||||
value_array: None,
|
||||
value_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建布尔条目
|
||||
pub fn boolean(key: impl Into<String>, value: bool) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
value_string: None,
|
||||
value_number: None,
|
||||
value_boolean: Some(value),
|
||||
value_array: None,
|
||||
value_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Map 条目
|
||||
pub fn map(key: impl Into<String>, entries: Vec<DataEntry>) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
value_string: None,
|
||||
value_number: None,
|
||||
value_boolean: None,
|
||||
value_array: None,
|
||||
value_map: Some(entries),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据模型更新消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataModelUpdate {
|
||||
pub surface_id: SurfaceId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<DataPath>,
|
||||
pub contents: Vec<DataEntry>,
|
||||
}
|
||||
|
||||
/// Surface 样式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SurfaceStyles {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub border_radius: Option<u32>,
|
||||
}
|
||||
|
||||
/// 开始渲染消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BeginRendering {
|
||||
pub surface_id: SurfaceId,
|
||||
pub root: ComponentId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub catalog_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub styles: Option<SurfaceStyles>,
|
||||
}
|
||||
|
||||
/// 删除 Surface 消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteSurface {
|
||||
pub surface_id: SurfaceId,
|
||||
}
|
||||
|
||||
/// 服务端消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UIMessage {
|
||||
SurfaceUpdate(SurfaceUpdate),
|
||||
DataModelUpdate(DataModelUpdate),
|
||||
BeginRendering(BeginRendering),
|
||||
DeleteSurface(DeleteSurface),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息类型 (Client → Server)
|
||||
// ============================================================================
|
||||
|
||||
/// 用户操作消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserAction {
|
||||
pub name: String,
|
||||
pub surface_id: SurfaceId,
|
||||
pub source_component_id: ComponentId,
|
||||
pub context: HashMap<String, serde_json::Value>,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Surface 定义
|
||||
// ============================================================================
|
||||
|
||||
/// Surface 定义 - 插件返回的初始 UI 定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SurfaceDefinition {
|
||||
/// Surface ID
|
||||
pub surface_id: SurfaceId,
|
||||
/// 根组件 ID
|
||||
pub root_id: ComponentId,
|
||||
/// 初始组件列表
|
||||
pub initial_components: Vec<ComponentDef>,
|
||||
/// 初始数据
|
||||
pub initial_data: serde_json::Value,
|
||||
/// 样式
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub styles: Option<SurfaceStyles>,
|
||||
}
|
||||
|
||||
impl SurfaceDefinition {
|
||||
/// 转换为 UI 消息列表
|
||||
pub fn to_messages(&self) -> Vec<UIMessage> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// 1. Surface 更新
|
||||
messages.push(UIMessage::SurfaceUpdate(SurfaceUpdate {
|
||||
surface_id: self.surface_id.clone(),
|
||||
components: self.initial_components.clone(),
|
||||
}));
|
||||
|
||||
// 2. 数据模型更新
|
||||
if let Some(obj) = self.initial_data.as_object() {
|
||||
let contents: Vec<DataEntry> = obj
|
||||
.iter()
|
||||
.filter_map(|(k, v)| json_to_data_entry(k, v))
|
||||
.collect();
|
||||
|
||||
if !contents.is_empty() {
|
||||
messages.push(UIMessage::DataModelUpdate(DataModelUpdate {
|
||||
surface_id: self.surface_id.clone(),
|
||||
path: None,
|
||||
contents,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 开始渲染
|
||||
messages.push(UIMessage::BeginRendering(BeginRendering {
|
||||
surface_id: self.surface_id.clone(),
|
||||
root: self.root_id.clone(),
|
||||
catalog_id: None,
|
||||
styles: self.styles.clone(),
|
||||
}));
|
||||
|
||||
messages
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 JSON 值转换为 DataEntry
|
||||
fn json_to_data_entry(key: &str, value: &serde_json::Value) -> Option<DataEntry> {
|
||||
match value {
|
||||
serde_json::Value::String(s) => Some(DataEntry::string(key, s)),
|
||||
serde_json::Value::Number(n) => n.as_f64().map(|f| DataEntry::number(key, f)),
|
||||
serde_json::Value::Bool(b) => Some(DataEntry::boolean(key, *b)),
|
||||
serde_json::Value::Object(obj) => {
|
||||
let entries: Vec<DataEntry> = obj
|
||||
.iter()
|
||||
.filter_map(|(k, v)| json_to_data_entry(k, v))
|
||||
.collect();
|
||||
Some(DataEntry::map(key, entries))
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
let entries: Vec<DataEntry> = arr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, v)| json_to_data_entry(&i.to_string(), v))
|
||||
.collect();
|
||||
Some(DataEntry {
|
||||
key: key.to_string(),
|
||||
value_string: None,
|
||||
value_number: None,
|
||||
value_boolean: None,
|
||||
value_array: Some(entries),
|
||||
value_map: None,
|
||||
})
|
||||
}
|
||||
serde_json::Value::Null => None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user