feat: 插件系统完整实现 v0.22.0

- 实现插件安装器 (URL/本地/GitHub 安装)
- 实现插件 UI 渲染系统
- 支持 builtin 类型插件 (内置 React 组件)
- 支持 binary 类型插件 (外部二进制)
- MachineIdTool 迁移为插件
- 工具箱页面动态加载插件工具
- 推荐插件一键安装功能
- 插件卸载功能
- Release workflow 自动打包插件
This commit is contained in:
coso
2025-12-30 00:47:26 +08:00
parent a7b2a38d7a
commit 303db47193
68 changed files with 11579 additions and 93 deletions
+67
View File
@@ -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());
}
}
+7
View File
@@ -0,0 +1,7 @@
//! 示例插件模块
//!
//! 提供插件 UI 系统的使用示例
pub mod credential_monitor;
pub use credential_monitor::CredentialMonitorPlugin;
+90
View File
@@ -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");
}
}
+959
View File
@@ -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(())
})?;
}
}
}
+23
View File
@@ -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;
+421
View File
@@ -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"),
}
}
}
+446
View File
@@ -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"),
}
}
}
+977
View File
@@ -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");
}
}
}
+44
View File
@@ -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 {
+14
View File
@@ -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;
+4
View File
@@ -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,
};
// 序列化
+260 -4
View File
@@ -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");
}
}
+271
View File
@@ -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
}
}
+104
View File
@@ -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()
}
}
+44
View File
@@ -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
}
}
+695
View File
@@ -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,
}
}