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
+37
View File
@@ -15,6 +15,24 @@ permissions:
contents: write
jobs:
# 打包插件
package-plugins:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Package machine-id-tool plugin
run: |
cd plugins
zip -j machine-id-tool.zip machine-id-tool/plugin.json machine-id-tool/config.json
- name: Upload plugin artifact
uses: actions/upload-artifact@v4
with:
name: plugins
path: plugins/*.zip
build:
strategy:
fail-fast: false
@@ -102,3 +120,22 @@ jobs:
releaseDraft: false
prerelease: false
args: --target ${{ matrix.target }}
# 上传插件到 Release
upload-plugins:
needs: [build, package-plugins]
runs-on: ubuntu-latest
steps:
- name: Download plugin artifacts
uses: actions/download-artifact@v4
with:
name: plugins
path: plugins
- name: Upload plugins to release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.event.inputs.version || github.ref_name }}
files: plugins/*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+462
View File
@@ -0,0 +1,462 @@
# ProxyCast Plugin UI 系统设计
## 概述
借鉴 A2UI 的设计理念,为 ProxyCast 设计一套声明式的插件 UI 系统。核心思想是:
- **安全如数据,表达如代码**:插件只能声明 UI 结构,不能执行任意代码
- **声明式 JSON 格式**:插件通过 JSON 描述 UI 意图,宿主应用负责渲染
- **组件目录(Catalog)机制**:预定义可用组件集,插件只能使用目录中的组件
- **数据绑定分离**:UI 结构与数据模型分离,支持增量更新
## 架构设计
```
┌─────────────────────────────────────────────────────────────────┐
│ ProxyCast Host │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Plugin UI Renderer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ Component │ │ Data │ │ Event │ │ │
│ │ │ Registry │ │ Store │ │ Handler │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ JSON Messages │
│ ┌───────────────────────────┼─────────────────────────────┐ │
│ │ Plugin Bridge │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ Tauri │ │ Schema │ │ Message │ │ │
│ │ │ IPC │ │ Validator │ │ Router │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
▲
│
┌───────────────┴───────────────┐
│ Plugin (Rust) │
│ ┌─────────────────────────┐ │
│ │ UI Declaration API │ │
│ │ - surface_update() │ │
│ │ - data_update() │ │
│ │ - begin_rendering() │ │
│ └─────────────────────────┘ │
└───────────────────────────────┘
```
## 核心概念
### 1. Surface(渲染表面)
每个插件可以拥有一个或多个 Surface,代表独立的 UI 区域:
```typescript
interface Surface {
surfaceId: string; // 唯一标识
pluginId: string; // 所属插件
rootComponentId: string; // 根组件 ID
components: Map<string, Component>; // 组件缓冲区
dataModel: Record<string, any>; // 数据模型
styles?: SurfaceStyles; // 样式配置
}
```
### 2. Component Catalog(组件目录)
预定义的安全组件集,插件只能使用这些组件:
```typescript
// 标准组件目录
const StandardCatalog = {
// 布局组件
Row: { children: 'ComponentRef[]', gap?: 'number', align?: 'Alignment' },
Column: { children: 'ComponentRef[]', gap?: 'number', align?: 'Alignment' },
Card: { child: 'ComponentRef', title?: 'BoundValue<string>' },
Tabs: { items: 'TabItem[]' },
// 展示组件
Text: { text: 'BoundValue<string>', variant?: 'TextVariant' },
Icon: { name: 'IconName', size?: 'number', color?: 'string' },
Badge: { text: 'BoundValue<string>', variant?: 'BadgeVariant' },
Progress: { value: 'BoundValue<number>', max?: 'number' },
// 输入组件
Button: { child: 'ComponentRef', action: 'Action', variant?: 'ButtonVariant' },
TextField: { label: 'BoundValue<string>', value: 'BoundValue<string>' },
Switch: { label: 'BoundValue<string>', checked: 'BoundValue<boolean>' },
Select: { options: 'SelectOption[]', value: 'BoundValue<string>' },
// 数据展示
Table: { columns: 'TableColumn[]', data: 'BoundValue<any[]>' },
List: { children: 'ChildrenDef', direction?: 'Direction' },
KeyValue: { items: 'KeyValueItem[]' },
// 反馈组件
Alert: { message: 'BoundValue<string>', type: 'AlertType' },
Spinner: { size?: 'number' },
Empty: { description?: 'BoundValue<string>' },
};
```
### 3. 消息协议
#### Server → Client 消息
```typescript
// 组件更新
interface SurfaceUpdate {
surfaceId: string;
components: ComponentDef[];
}
// 数据更新
interface DataModelUpdate {
surfaceId: string;
path?: string; // JSONPath,如 '/credentials/0/status'
contents: DataEntry[];
}
// 开始渲染
interface BeginRendering {
surfaceId: string;
root: string; // 根组件 ID
catalogId?: string;
styles?: SurfaceStyles;
}
// 删除 Surface
interface DeleteSurface {
surfaceId: string;
}
```
#### Client → Server 消息
```typescript
// 用户操作
interface UserAction {
name: string; // 操作名称
surfaceId: string;
sourceComponentId: string;
context: Record<string, any>; // 解析后的上下文数据
timestamp: string;
}
```
### 4. 数据绑定
支持字面值和路径绑定:
```typescript
type BoundValue<T> =
| { literal: T } // 字面值
| { path: string } // 数据路径
| { literal: T; path: string }; // 初始化 + 绑定
// 示例
const textComponent = {
id: 'status-text',
component: {
Text: {
text: { path: '/credential/status' }, // 绑定到数据模型
variant: 'body'
}
}
};
```
## 实现方案
### 前端:React Renderer
```
src/lib/plugin-ui/
├── index.ts # 导出入口
├── types.ts # 类型定义
├── PluginUIRenderer.tsx # 主渲染器组件
├── PluginSurface.tsx # Surface 容器
├── ComponentRegistry.ts # 组件注册表
├── DataStore.ts # 数据存储
├── MessageHandler.ts # 消息处理
└── components/ # 标准组件实现
├── layout/
│ ├── Row.tsx
│ ├── Column.tsx
│ ├── Card.tsx
│ └── Tabs.tsx
├── display/
│ ├── Text.tsx
│ ├── Icon.tsx
│ ├── Badge.tsx
│ └── Progress.tsx
├── input/
│ ├── Button.tsx
│ ├── TextField.tsx
│ ├── Switch.tsx
│ └── Select.tsx
└── data/
├── Table.tsx
├── List.tsx
└── KeyValue.tsx
```
### 后端:Rust Plugin API
```rust
// src-tauri/src/plugins/ui_api.rs
/// 插件 UI 声明 API
pub trait PluginUI {
/// 获取插件的 Surface 定义
fn get_surfaces(&self) -> Vec<SurfaceDefinition>;
/// 处理用户操作
fn handle_action(&mut self, action: UserAction) -> Result<Vec<UIMessage>>;
}
/// UI 消息类型
pub enum UIMessage {
SurfaceUpdate(SurfaceUpdate),
DataModelUpdate(DataModelUpdate),
BeginRendering(BeginRendering),
DeleteSurface(DeleteSurface),
}
/// Surface 定义
pub struct SurfaceDefinition {
pub surface_id: String,
pub initial_components: Vec<ComponentDef>,
pub initial_data: serde_json::Value,
pub root_id: String,
}
```
## 使用示例
### 插件端(Rust)
```rust
impl PluginUI for CredentialMonitorPlugin {
fn get_surfaces(&self) -> Vec<SurfaceDefinition> {
vec![SurfaceDefinition {
surface_id: "credential-monitor".into(),
root_id: "root".into(),
initial_components: vec![
component!("root", Column {
children: explicit_list!["header", "credential-list"],
gap: 16
}),
component!("header", Row {
children: explicit_list!["title", "refresh-btn"],
align: "spaceBetween"
}),
component!("title", Text {
text: literal!("凭证监控"),
variant: "h3"
}),
component!("refresh-btn", Button {
child: "refresh-icon",
action: action!("refresh")
}),
component!("refresh-icon", Icon { name: "refresh" }),
component!("credential-list", List {
children: template!("credential-item", "/credentials"),
direction: "vertical"
}),
// 模板组件
component!("credential-item", Card {
child: "item-content"
}),
component!("item-content", Row {
children: explicit_list!["item-name", "item-status"]
}),
component!("item-name", Text {
text: path!("name") // 相对路径,从列表项数据解析
}),
component!("item-status", Badge {
text: path!("status"),
variant: path!("statusVariant")
}),
],
initial_data: json!({
"credentials": []
}),
}]
}
fn handle_action(&mut self, action: UserAction) -> Result<Vec<UIMessage>> {
match action.name.as_str() {
"refresh" => {
let credentials = self.fetch_credentials()?;
Ok(vec![UIMessage::DataModelUpdate(DataModelUpdate {
surface_id: "credential-monitor".into(),
path: Some("/credentials".into()),
contents: credentials.into_data_entries(),
})])
}
_ => Ok(vec![])
}
}
}
```
### 宿主端(React)
```tsx
// 在插件详情页使用
function PluginDetailPage({ pluginId }: { pluginId: string }) {
return (
<div className="plugin-detail">
<PluginInfo pluginId={pluginId} />
{/* 插件 UI 渲染区域 */}
<PluginUIRenderer
pluginId={pluginId}
onAction={(action) => invoke('plugin_handle_action', { pluginId, action })}
/>
</div>
);
}
```
## 安全考虑
1. **组件白名单**:只允许使用预定义的组件类型
2. **Schema 验证**:所有消息必须通过 JSON Schema 验证
3. **沙箱隔离**:每个插件的 Surface 相互隔离
4. **Action 审计**:记录所有用户操作,支持权限控制
5. **资源限制**:限制组件数量、数据大小等
## 扩展机制
### 自定义组件注册
允许宿主应用注册额外的组件:
```typescript
// 注册自定义组件
componentRegistry.register('CredentialCard', CredentialCardComponent, {
schema: {
credential: { type: 'object', required: true },
onRefresh: { type: 'action' }
}
});
```
### 主题支持
通过 Surface styles 支持主题定制:
```typescript
interface SurfaceStyles {
primaryColor?: string;
font?: string;
borderRadius?: number;
// ... 更多样式属性
}
```
## 迁移路径
1. **Phase 1**:实现核心渲染器和基础组件
2. **Phase 2**:添加数据绑定和事件处理
3. **Phase 3**:迁移现有插件 UI 到新系统
4. **Phase 4**:支持自定义组件扩展
## 与 A2UI 的差异
| 特性 | A2UI | ProxyCast Plugin UI |
|------|------|---------------------|
| 传输方式 | SSE/JSONL 流 | Tauri IPC |
| 渲染框架 | Lit/Angular/Flutter | React |
| 组件风格 | Material Design | TailwindCSS/shadcn |
| 数据更新 | 增量流式 | 批量更新 |
| 使用场景 | 跨平台 Agent UI | 桌面应用插件 |
## 实时更新:Tauri 事件推送
插件可以通过 Tauri 事件系统向前端推送 UI 更新,实现实时数据刷新。
### 事件发射器
```rust
use crate::plugin::{PluginUIEmitter, UIMessage, DataModelUpdate, DataEntry};
// 在 Tauri 命令或服务中使用
fn update_plugin_ui(emitter: &PluginUIEmitter, plugin_id: &str) {
// 发送数据更新
let update = DataModelUpdate {
surface_id: "my-surface".into(),
path: Some("/stats".into()),
contents: vec![
DataEntry::number("count", 42.0),
DataEntry::string("status", "healthy"),
],
};
emitter.emit_data_update(plugin_id, update).unwrap();
}
```
### 前端监听
前端通过 `usePluginUI` Hook 自动监听 `plugin-ui-message` 事件:
```typescript
// 自动处理,无需手动监听
const { surfaces, handleAction } = usePluginUI({ pluginId: 'my-plugin' });
```
### 事件载荷格式
```typescript
interface PluginUIEventPayload {
pluginId: string;
message: UIMessage; // SurfaceUpdate | DataModelUpdate | BeginRendering | DeleteSurface
}
```
## 示例插件:凭证监控
完整示例见 `src-tauri/src/plugin/examples/credential_monitor.rs`:
```rust
use crate::plugin::{PluginUI, SurfaceDefinition, ComponentDef, ChildrenDef, BoundValue};
struct CredentialMonitorPlugin { /* ... */ }
impl PluginUI for CredentialMonitorPlugin {
fn get_surfaces(&self) -> Vec<SurfaceDefinition> {
vec![SurfaceDefinition {
surface_id: "credential-monitor".into(),
root_id: "root".into(),
initial_components: vec![
ComponentDef::column("root", ChildrenDef::explicit(vec!["header", "list"])),
ComponentDef::text_literal("header", "凭证监控"),
ComponentDef::list("list", ChildrenDef::template("item", "/credentials")),
// ... 更多组件
],
initial_data: json!({ "credentials": [] }),
styles: None,
}]
}
async fn handle_action(&mut self, action: UserAction) -> Result<Vec<UIMessage>, PluginError> {
match action.name.as_str() {
"refresh" => {
// 返回数据更新消息
Ok(vec![UIMessage::DataModelUpdate(/* ... */)])
}
_ => Ok(vec![])
}
}
}
```
## 下一步计划
1. **更多组件**:Table、Tabs、Modal 等复杂组件
2. **表单验证**:支持 TextField 的验证规则
3. **主题系统**:更完善的样式定制能力
4. **插件市场**:支持从远程加载插件 UI 定义
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.21.0",
"version": "0.22.0",
"type": "module",
"repository": {
"type": "git",
+74
View File
@@ -0,0 +1,74 @@
# ProxyCast 插件目录
此目录包含 ProxyCast 的官方插件源代码。
## 目录结构
```
plugins/
├── README.md # 本文件
├── machine-id-tool/ # Machine ID 管理工具插件
│ └── plugin.json # 插件清单文件
└── ... # 其他插件
```
## 插件清单格式 (plugin.json)
每个插件必须包含一个 `plugin.json` 清单文件,定义插件的元数据:
```json
{
"name": "plugin-name",
"version": "0.1.0",
"description": "插件描述",
"author": "作者名",
"homepage": "https://github.com/...",
"license": "MIT",
"plugin_type": "binary",
"entry": "plugin-name",
"hooks": [],
"min_proxycast_version": "1.0.0",
"binary": {
"binary_name": "plugin-name",
"github_owner": "owner",
"github_repo": "repo",
"platform_binaries": {
"macos-arm64": "binary-aarch64-apple-darwin",
"macos-x64": "binary-x86_64-apple-darwin",
"linux-x64": "binary-x86_64-unknown-linux-gnu",
"linux-arm64": "binary-aarch64-unknown-linux-gnu",
"windows-x64": "binary-x86_64-pc-windows-msvc.exe"
}
},
"ui": {
"surfaces": ["main"],
"icon": "icon-name",
"title": "插件标题"
}
}
```
## 构建插件包
使用构建脚本将插件打包为 zip 格式:
```bash
# 打包指定插件
./scripts/build-plugin.sh machine-id-tool
# 指定版本号
./scripts/build-plugin.sh machine-id-tool 0.2.0
```
输出文件将保存在 `dist/plugins/` 目录下。
## 插件类型
- `script`: 脚本插件(JSON 配置驱动)
- `native`: 原生 Rust 插件(预留)
- `binary`: 二进制可执行文件插件
## 相关文档
- [插件安装机制设计文档](.kiro/specs/plugin-installation/design.md)
- [插件系统 README](src-tauri/src/plugin/README.md)
+4
View File
@@ -0,0 +1,4 @@
{
"enabled": true,
"settings": {}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "machine-id-tool",
"version": "0.2.0",
"description": "Machine ID 管理工具 - 查看、修改和管理系统机器码,支持跨平台操作",
"author": "ProxyCast Team",
"homepage": "https://github.com/aiclientproxy/MachineIdTool",
"license": "MIT",
"plugin_type": "binary",
"entry": "machine-id-tool-cli",
"hooks": [],
"min_proxycast_version": "1.0.0",
"binary": {
"binary_name": "machine-id-tool-cli",
"github_owner": "aiclientproxy",
"github_repo": "MachineIdTool",
"platform_binaries": {
"macos-arm64": "machine-id-tool-aarch64-apple-darwin",
"macos-x64": "machine-id-tool-x86_64-apple-darwin",
"linux-x64": "machine-id-tool-x86_64-unknown-linux-gnu",
"linux-arm64": "machine-id-tool-aarch64-unknown-linux-gnu",
"windows-x64": "machine-id-tool-x86_64-pc-windows-msvc.exe"
},
"checksum_file": "checksums.txt"
},
"ui": {
"surfaces": ["tools"],
"icon": "Cpu",
"title": "机器码管理工具",
"default_width": 800,
"default_height": 600
}
}
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# 插件包构建脚本
# 用法: ./scripts/build-plugin.sh <plugin_name> [version]
# 示例: ./scripts/build-plugin.sh machine-id-tool 0.1.0
#
# 此脚本将插件目录打包为 zip 格式,用于分发和安装
# _需求: 6.1_
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 打印带颜色的消息
info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
exit 1
}
# 检查参数
if [ -z "$1" ]; then
echo "用法: $0 <plugin_name> [version]"
echo "示例: $0 machine-id-tool 0.1.0"
echo ""
echo "参数:"
echo " plugin_name 插件名称(对应 plugins/ 目录下的文件夹名)"
echo " version 可选,覆盖 plugin.json 中的版本号"
exit 1
fi
PLUGIN_NAME="$1"
VERSION_OVERRIDE="$2"
# 获取脚本和项目目录
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
PLUGIN_DIR="$PLUGINS_DIR/$PLUGIN_NAME"
OUTPUT_DIR="$PROJECT_ROOT/dist/plugins"
# 检查插件目录是否存在
if [ ! -d "$PLUGIN_DIR" ]; then
error "插件目录不存在: $PLUGIN_DIR"
fi
# 检查 plugin.json 是否存在
MANIFEST_FILE="$PLUGIN_DIR/plugin.json"
if [ ! -f "$MANIFEST_FILE" ]; then
error "插件清单文件不存在: $MANIFEST_FILE"
fi
# 读取版本号
if [ -n "$VERSION_OVERRIDE" ]; then
VERSION="$VERSION_OVERRIDE"
info "使用覆盖版本: $VERSION"
else
# 从 plugin.json 读取版本
VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$MANIFEST_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
if [ -z "$VERSION" ]; then
error "无法从 plugin.json 读取版本号"
fi
info "从 plugin.json 读取版本: $VERSION"
fi
# 创建输出目录
mkdir -p "$OUTPUT_DIR"
# 定义输出文件名
OUTPUT_FILE="$OUTPUT_DIR/${PLUGIN_NAME}-${VERSION}.zip"
info "开始打包插件: $PLUGIN_NAME v$VERSION"
info "源目录: $PLUGIN_DIR"
info "输出文件: $OUTPUT_FILE"
# 如果输出文件已存在,先删除
if [ -f "$OUTPUT_FILE" ]; then
warn "输出文件已存在,将被覆盖"
rm -f "$OUTPUT_FILE"
fi
# 创建临时目录用于打包
TEMP_DIR=$(mktemp -d)
TEMP_PLUGIN_DIR="$TEMP_DIR/$PLUGIN_NAME"
# 复制插件文件到临时目录
info "复制插件文件..."
mkdir -p "$TEMP_PLUGIN_DIR"
cp -r "$PLUGIN_DIR"/* "$TEMP_PLUGIN_DIR/"
# 如果指定了版本覆盖,更新 plugin.json
if [ -n "$VERSION_OVERRIDE" ]; then
info "更新 plugin.json 版本号..."
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
sed -i '' "s/\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"version\": \"$VERSION_OVERRIDE\"/" "$TEMP_PLUGIN_DIR/plugin.json"
else
# Linux
sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"version\": \"$VERSION_OVERRIDE\"/" "$TEMP_PLUGIN_DIR/plugin.json"
fi
fi
# 创建 zip 包
info "创建 zip 包..."
cd "$TEMP_DIR"
zip -r "$OUTPUT_FILE" "$PLUGIN_NAME" -x "*.DS_Store" -x "*__MACOSX*"
# 清理临时目录
rm -rf "$TEMP_DIR"
# 计算校验和
info "计算校验和..."
if command -v sha256sum &> /dev/null; then
CHECKSUM=$(sha256sum "$OUTPUT_FILE" | awk '{print $1}')
elif command -v shasum &> /dev/null; then
CHECKSUM=$(shasum -a 256 "$OUTPUT_FILE" | awk '{print $1}')
else
warn "无法计算校验和:未找到 sha256sum 或 shasum 命令"
CHECKSUM="N/A"
fi
# 输出结果
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}插件打包完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "插件名称: $PLUGIN_NAME"
echo "版本: $VERSION"
echo "输出文件: $OUTPUT_FILE"
echo "文件大小: $(du -h "$OUTPUT_FILE" | cut -f1)"
echo "SHA256: $CHECKSUM"
echo ""
echo "安装方式:"
echo " 1. 在 ProxyCast 中打开插件管理器"
echo " 2. 点击「从文件安装」"
echo " 3. 选择 $OUTPUT_FILE"
+23 -1
View File
@@ -3668,7 +3668,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.21.0"
version = "0.22.0"
dependencies = [
"anyhow",
"arboard",
@@ -3704,6 +3704,7 @@ dependencies = [
"serde_yaml",
"sha2",
"subtle",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
@@ -4854,6 +4855,17 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -6886,6 +6898,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.1"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.21.0"
version = "0.22.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -41,6 +41,7 @@ md5 = "0.7"
urlencoding = "2"
subtle = "2.5"
flate2 = "1"
tar = "0.4"
fs2 = "0.4"
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
serde_yaml = "0.9"
+1 -1
View File
@@ -18,7 +18,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
- `injection/` - 请求注入(系统提示词等)
- `middleware/` - HTTP 中间件
- `models/` - 数据模型定义
- `plugin/` - 插件系统
- `plugin/` - 插件系统(含声明式 UI 系统)
- `processor/` - 请求处理管道
- `providers/` - 各 Provider 的认证和 API 实现
- `proxy/` - HTTP 代理客户端
+1
View File
@@ -11,6 +11,7 @@ pub mod mcp_cmd;
pub mod network_cmd;
pub mod oauth_cmd;
pub mod plugin_cmd;
pub mod plugin_install_cmd;
pub mod prompt_cmd;
pub mod provider_pool_cmd;
pub mod resilience_cmd;
+133 -1
View File
@@ -1,10 +1,22 @@
//! 插件系统相关命令
//!
//! 提供插件管理和 UI 相关的 Tauri 命令:
//! - get_plugin_status: 获取插件服务状态
//! - get_plugins: 获取所有插件列表
//! - get_plugins_with_ui: 获取带有 UI 配置的已安装插件列表
//! - get_plugin_ui: 获取插件 UI 定义
//! - handle_plugin_action: 处理插件 UI 操作
//!
//! _需求: 3.1, 3.2, 3.3_
use crate::plugin::{PluginConfig, PluginInfo, PluginManager};
use crate::plugin::{PluginConfig, PluginInfo, PluginManager, PluginManifest};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::plugin_install_cmd::PluginInstallerState;
/// 插件管理器状态
pub struct PluginManagerState(pub Arc<RwLock<PluginManager>>);
@@ -132,3 +144,123 @@ pub async fn get_plugins_dir(
let manager = state.0.read().await;
Ok(manager.plugins_dir().to_string_lossy().to_string())
}
// ============================================================================
// 插件 UI 注册系统
// ============================================================================
/// 插件 UI 信息
///
/// 用于前端显示带有 UI 的插件列表
/// _需求: 3.1, 3.3_
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginUIInfo {
/// 插件 ID
pub plugin_id: String,
/// 插件名称
pub name: String,
/// 插件描述
pub description: String,
/// 图标名称 (Lucide 图标)
pub icon: String,
/// UI 展示位置列表 (如 "tools", "sidebar", "main")
pub surfaces: Vec<String>,
}
/// 从插件目录读取 manifest 文件
///
/// 尝试读取 plugin.json 文件并解析为 PluginManifest
fn read_plugin_manifest(install_path: &Path) -> Option<PluginManifest> {
let manifest_path = install_path.join("plugin.json");
if !manifest_path.exists() {
return None;
}
let content = std::fs::read_to_string(&manifest_path).ok()?;
serde_json::from_str(&content).ok()
}
/// 获取带有 UI 配置的已安装插件列表
///
/// 从已安装插件中筛选带有 UI 配置的插件,返回 PluginUIInfo 列表
/// _需求: 3.1, 3.3_
#[tauri::command]
pub async fn get_plugins_with_ui(
installer_state: tauri::State<'_, PluginInstallerState>,
) -> Result<Vec<PluginUIInfo>, String> {
let installer = installer_state.0.read().await;
// 获取所有已安装插件
let installed_plugins = installer.list_installed().map_err(|e| e.to_string())?;
// 筛选带有 UI 配置的插件
let ui_plugins: Vec<PluginUIInfo> = installed_plugins
.into_iter()
.filter_map(|plugin| {
// 读取插件的 manifest 文件
let manifest = read_plugin_manifest(&plugin.install_path)?;
// 检查是否有 UI 配置
let ui_config = manifest.ui?;
// 只返回有 surfaces 配置的插件
if ui_config.surfaces.is_empty() {
return None;
}
Some(PluginUIInfo {
plugin_id: plugin.id,
name: plugin.name,
description: plugin.description,
icon: ui_config.icon.unwrap_or_else(|| "puzzle".to_string()),
surfaces: ui_config.surfaces,
})
})
.collect();
Ok(ui_plugins)
}
// ============================================================================
// 插件 UI 相关命令
// ============================================================================
use crate::plugin::{UIMessage, UserAction};
/// 获取插件 UI 定义
/// 返回插件的初始 UI 消息列表
#[tauri::command]
pub async fn get_plugin_ui(
state: tauri::State<'_, PluginManagerState>,
plugin_id: String,
) -> Result<Vec<UIMessage>, String> {
let manager = state.0.read().await;
// 获取插件的 Surface 定义
let surfaces = manager
.get_plugin_surfaces(&plugin_id)
.await
.map_err(|e| e.to_string())?;
// 转换为 UI 消息
let messages: Vec<UIMessage> = surfaces.into_iter().flat_map(|s| s.to_messages()).collect();
Ok(messages)
}
/// 处理插件 UI 操作
/// 将用户操作转发给插件并返回响应消息
#[tauri::command]
pub async fn handle_plugin_action(
state: tauri::State<'_, PluginManagerState>,
plugin_id: String,
action: UserAction,
) -> Result<Vec<UIMessage>, String> {
let mut manager = state.0.write().await;
manager
.handle_plugin_action(&plugin_id, action)
.await
.map_err(|e| e.to_string())
}
@@ -0,0 +1,189 @@
//! 插件安装相关命令
//!
//! 提供插件安装、卸载和管理的 Tauri 命令:
//! - install_plugin_from_file: 从本地文件安装插件
//! - install_plugin_from_url: 从 URL 安装插件
//! - uninstall_plugin: 卸载插件
//! - list_installed_plugins: 列出已安装插件
//!
//! _需求: 1.1, 2.1, 2.2, 2.4, 3.1, 3.2, 3.3, 4.2, 6.1_
use crate::plugin::installer::{
InstallProgress, InstalledPlugin, PluginInstaller, ProgressCallback,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Runtime};
use tokio::sync::RwLock;
/// 插件安装器状态
pub struct PluginInstallerState(pub Arc<RwLock<PluginInstaller>>);
/// 安装结果响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallResult {
pub success: bool,
pub plugin: Option<InstalledPlugin>,
pub error: Option<String>,
}
/// 进度事件名称
const INSTALL_PROGRESS_EVENT: &str = "plugin-install-progress";
/// Tauri 进度回调实现
///
/// 将安装进度通过 Tauri 事件发送到前端
struct TauriProgressCallback<R: Runtime> {
app_handle: AppHandle<R>,
}
impl<R: Runtime> TauriProgressCallback<R> {
fn new(app_handle: AppHandle<R>) -> Self {
Self { app_handle }
}
}
impl<R: Runtime> crate::plugin::installer::ProgressCallback for TauriProgressCallback<R> {
fn on_progress(&self, progress: InstallProgress) {
// 发送进度事件到前端
let _ = self.app_handle.emit(INSTALL_PROGRESS_EVENT, &progress);
}
}
/// 从本地文件安装插件
///
/// 流程: 验证 → 解压 → 注册 → 复制文件
/// _需求: 1.1, 3.1, 3.2, 3.3_
#[tauri::command]
pub async fn install_plugin_from_file<R: Runtime>(
app_handle: AppHandle<R>,
state: tauri::State<'_, PluginInstallerState>,
file_path: String,
) -> Result<InstallResult, String> {
let installer = state.0.read().await;
let path = PathBuf::from(&file_path);
// 验证文件存在
if !path.exists() {
return Ok(InstallResult {
success: false,
plugin: None,
error: Some(format!("文件不存在: {}", file_path)),
});
}
// 创建进度回调
let progress_callback = TauriProgressCallback::new(app_handle);
// 执行安装
match installer.install_from_file(&path, &progress_callback).await {
Ok(plugin) => Ok(InstallResult {
success: true,
plugin: Some(plugin),
error: None,
}),
Err(e) => {
// 发送失败进度
progress_callback.on_progress(InstallProgress::failed(e.to_string()));
Ok(InstallResult {
success: false,
plugin: None,
error: Some(e.to_string()),
})
}
}
}
/// 从 URL 安装插件
///
/// 流程: 下载 → 验证 → 解压 → 注册 → 复制文件
/// _需求: 2.1, 2.2, 2.4_
#[tauri::command]
pub async fn install_plugin_from_url<R: Runtime>(
app_handle: AppHandle<R>,
state: tauri::State<'_, PluginInstallerState>,
url: String,
) -> Result<InstallResult, String> {
let installer = state.0.read().await;
// 验证 URL 格式
if !url.starts_with("http://") && !url.starts_with("https://") {
return Ok(InstallResult {
success: false,
plugin: None,
error: Some("无效的 URL 格式,必须以 http:// 或 https:// 开头".to_string()),
});
}
// 创建进度回调
let progress_callback = TauriProgressCallback::new(app_handle);
// 执行安装
match installer.install_from_url(&url, &progress_callback).await {
Ok(plugin) => Ok(InstallResult {
success: true,
plugin: Some(plugin),
error: None,
}),
Err(e) => {
// 发送失败进度
progress_callback.on_progress(InstallProgress::failed(e.to_string()));
Ok(InstallResult {
success: false,
plugin: None,
error: Some(e.to_string()),
})
}
}
}
/// 卸载插件
///
/// 流程: 删除文件 → 注销注册表
/// _需求: 4.2_
#[tauri::command]
pub async fn uninstall_plugin(
state: tauri::State<'_, PluginInstallerState>,
plugin_id: String,
) -> Result<bool, String> {
let installer = state.0.read().await;
match installer.uninstall(&plugin_id).await {
Ok(()) => Ok(true),
Err(e) => Err(e.to_string()),
}
}
/// 列出已安装插件
///
/// _需求: 6.1_
#[tauri::command]
pub async fn list_installed_plugins(
state: tauri::State<'_, PluginInstallerState>,
) -> Result<Vec<InstalledPlugin>, String> {
let installer = state.0.read().await;
installer.list_installed().map_err(|e| e.to_string())
}
/// 获取已安装插件信息
#[tauri::command]
pub async fn get_installed_plugin(
state: tauri::State<'_, PluginInstallerState>,
plugin_id: String,
) -> Result<Option<InstalledPlugin>, String> {
let installer = state.0.read().await;
installer.get_plugin(&plugin_id).map_err(|e| e.to_string())
}
/// 检查插件是否已安装
#[tauri::command]
pub async fn is_plugin_installed(
state: tauri::State<'_, PluginInstallerState>,
plugin_id: String,
) -> Result<bool, String> {
let installer = state.0.read().await;
installer
.is_installed(&plugin_id)
.map_err(|e| e.to_string())
}
@@ -0,0 +1,389 @@
//! 已安装插件数据访问对象
//!
//! 提供已安装插件的 CRUD 操作。
//! _需求: 1.2, 4.2_
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use std::path::PathBuf;
/// 安装来源
#[derive(Debug, Clone)]
pub enum InstallSource {
/// 本地文件
Local { path: String },
/// URL 下载
Url { url: String },
/// GitHub release
GitHub {
owner: String,
repo: String,
tag: String,
},
}
/// 已安装插件信息
#[derive(Debug, Clone)]
pub struct InstalledPluginRecord {
/// 插件 ID
pub id: String,
/// 插件名称
pub name: String,
/// 版本
pub version: String,
/// 描述
pub description: Option<String>,
/// 作者
pub author: Option<String>,
/// 安装路径
pub install_path: PathBuf,
/// 安装时间
pub installed_at: DateTime<Utc>,
/// 安装来源
pub source: InstallSource,
/// 是否启用
pub enabled: bool,
}
/// 数据库行结构
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_record(self) -> Result<InstalledPluginRecord, String> {
let source = deserialize_source(&self.source_type, self.source_data.as_deref())?;
let installed_at = DateTime::parse_from_rfc3339(&self.installed_at)
.map_err(|e| format!("无效的时间格式: {}", e))?
.with_timezone(&Utc);
Ok(InstalledPluginRecord {
id: self.id,
name: self.name,
version: self.version,
description: self.description,
author: self.author,
install_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, String> {
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("{}"))
.map_err(|e| format!("JSON 解析错误: {}", e))?;
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(format!("未知的来源类型: {}", source_type)),
}
}
pub struct InstalledPluginsDao;
impl InstalledPluginsDao {
/// 注册插件
///
/// _需求: 1.2_
pub fn register(
conn: &Connection,
plugin: &InstalledPluginRecord,
) -> Result<(), rusqlite::Error> {
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,
],
)?;
Ok(())
}
/// 注销插件
///
/// _需求: 4.2_
pub fn unregister(conn: &Connection, plugin_id: &str) -> Result<bool, rusqlite::Error> {
let rows_affected = conn.execute(
"DELETE FROM installed_plugins WHERE id = ?1",
params![plugin_id],
)?;
Ok(rows_affected > 0)
}
/// 获取插件信息
pub fn get(
conn: &Connection,
plugin_id: &str,
) -> Result<Option<InstalledPluginRecord>, 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| format!("数据库错误: {}", e))?;
match result {
Some(row) => Ok(Some(row.into_record()?)),
None => Ok(None),
}
}
/// 列出所有插件
pub fn list(conn: &Connection) -> Result<Vec<InstalledPluginRecord>, 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| format!("数据库错误: {}", e))?;
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| format!("数据库错误: {}", e))?;
let mut plugins = Vec::new();
for row in rows {
let row = row.map_err(|e| format!("数据库错误: {}", e))?;
plugins.push(row.into_record()?);
}
Ok(plugins)
}
/// 检查插件是否存在
pub fn exists(conn: &Connection, plugin_id: &str) -> Result<bool, rusqlite::Error> {
let count: i32 = conn.query_row(
"SELECT COUNT(*) FROM installed_plugins WHERE id = ?1",
params![plugin_id],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// 更新插件启用状态
pub fn set_enabled(
conn: &Connection,
plugin_id: &str,
enabled: bool,
) -> Result<bool, rusqlite::Error> {
let rows_affected = conn.execute(
"UPDATE installed_plugins SET enabled = ?1 WHERE id = ?2",
params![enabled as i32, plugin_id],
)?;
Ok(rows_affected > 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_connection() -> Connection {
let conn = Connection::open_in_memory().unwrap();
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
)",
[],
)
.unwrap();
conn
}
fn create_test_plugin(id: &str) -> InstalledPluginRecord {
InstalledPluginRecord {
id: id.to_string(),
name: format!("Test Plugin {}", id),
version: "1.0.0".to_string(),
description: Some("A test plugin".to_string()),
author: Some("Test Author".to_string()),
install_path: PathBuf::from(format!("/plugins/{}", id)),
installed_at: Utc::now(),
source: InstallSource::Local {
path: "/tmp/plugin.zip".to_string(),
},
enabled: true,
}
}
#[test]
fn test_register_and_get() {
let conn = create_test_connection();
let plugin = create_test_plugin("test-1");
InstalledPluginsDao::register(&conn, &plugin).unwrap();
let retrieved = InstalledPluginsDao::get(&conn, "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 conn = create_test_connection();
let plugin = create_test_plugin("test-2");
InstalledPluginsDao::register(&conn, &plugin).unwrap();
assert!(InstalledPluginsDao::exists(&conn, "test-2").unwrap());
let deleted = InstalledPluginsDao::unregister(&conn, "test-2").unwrap();
assert!(deleted);
assert!(!InstalledPluginsDao::exists(&conn, "test-2").unwrap());
}
#[test]
fn test_unregister_not_found() {
let conn = create_test_connection();
let deleted = InstalledPluginsDao::unregister(&conn, "non-existent").unwrap();
assert!(!deleted);
}
#[test]
fn test_list() {
let conn = create_test_connection();
InstalledPluginsDao::register(&conn, &create_test_plugin("test-a")).unwrap();
InstalledPluginsDao::register(&conn, &create_test_plugin("test-b")).unwrap();
let plugins = InstalledPluginsDao::list(&conn).unwrap();
assert_eq!(plugins.len(), 2);
}
#[test]
fn test_set_enabled() {
let conn = create_test_connection();
let plugin = create_test_plugin("test-3");
InstalledPluginsDao::register(&conn, &plugin).unwrap();
InstalledPluginsDao::set_enabled(&conn, "test-3", false).unwrap();
let retrieved = InstalledPluginsDao::get(&conn, "test-3").unwrap().unwrap();
assert!(!retrieved.enabled);
InstalledPluginsDao::set_enabled(&conn, "test-3", true).unwrap();
let retrieved = InstalledPluginsDao::get(&conn, "test-3").unwrap().unwrap();
assert!(retrieved.enabled);
}
#[test]
fn test_github_source_serialization() {
let conn = create_test_connection();
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(),
};
InstalledPluginsDao::register(&conn, &plugin).unwrap();
let retrieved = InstalledPluginsDao::get(&conn, "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"),
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod installed_plugins;
pub mod mcp;
pub mod prompts;
pub mod provider_pool;
+24
View File
@@ -160,6 +160,30 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
// Migration: 添加代理URL字段 - 使用重建表结构的方式
migrate_add_proxy_url_column(conn)?;
// 已安装插件表
// _需求: 1.2, 1.3_
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
)",
[],
)?;
// 创建 installed_plugins 索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_installed_plugins_name ON installed_plugins(name)",
[],
)?;
Ok(())
}
+53
View File
@@ -38,6 +38,7 @@ use commands::flow_monitor_cmd::{
};
use commands::machine_id_cmd::MachineIdState;
use commands::plugin_cmd::PluginManagerState;
use commands::plugin_install_cmd::PluginInstallerState;
use commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState};
use commands::resilience_cmd::ResilienceConfigState;
use commands::router_cmd::RouterConfigState;
@@ -1551,6 +1552,48 @@ pub fn run() {
let plugin_manager = plugin::PluginManager::with_defaults();
let plugin_manager_state = PluginManagerState(Arc::new(RwLock::new(plugin_manager)));
// Initialize PluginInstaller
let plugin_installer_state = {
let db_path =
database::get_db_path().expect("Failed to get database path for PluginInstaller");
let plugins_dir = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("proxycast")
.join("plugins");
let temp_dir = std::env::temp_dir().join("proxycast_plugin_install");
// 创建目录(如果不存在)
if let Err(e) = std::fs::create_dir_all(&plugins_dir) {
tracing::warn!("无法创建插件目录: {}", e);
}
if let Err(e) = std::fs::create_dir_all(&temp_dir) {
tracing::warn!("无法创建插件临时目录: {}", e);
}
match plugin::installer::PluginInstaller::from_paths(plugins_dir, temp_dir, &db_path) {
Ok(installer) => {
tracing::info!("[启动] 插件安装器初始化成功");
PluginInstallerState(Arc::new(RwLock::new(installer)))
}
Err(e) => {
tracing::error!("[启动] 插件安装器初始化失败: {}", e);
// 创建一个默认的安装器(使用临时目录)
let fallback_plugins_dir = std::env::temp_dir().join("proxycast_plugins_fallback");
let fallback_temp_dir =
std::env::temp_dir().join("proxycast_plugin_install_fallback");
let _ = std::fs::create_dir_all(&fallback_plugins_dir);
let _ = std::fs::create_dir_all(&fallback_temp_dir);
let installer = plugin::installer::PluginInstaller::from_paths(
fallback_plugins_dir,
fallback_temp_dir,
&db_path,
)
.expect("Failed to create fallback PluginInstaller");
PluginInstallerState(Arc::new(RwLock::new(installer)))
}
}
};
// Initialize shared telemetry instances for both TelemetryState and RequestProcessor
// This allows the frontend monitoring page to display data recorded by the request processor
let shared_stats = Arc::new(parking_lot::RwLock::new(
@@ -1739,6 +1782,7 @@ pub fn run() {
.manage(resilience_config_state)
.manage(telemetry_state)
.manage(plugin_manager_state)
.manage(plugin_installer_state)
.manage(flow_monitor_state)
.manage(flow_query_service_state)
.manage(flow_interceptor_state)
@@ -2188,6 +2232,15 @@ pub fn run() {
commands::plugin_cmd::reload_plugins,
commands::plugin_cmd::unload_plugin,
commands::plugin_cmd::get_plugins_dir,
// Plugin Install commands
commands::plugin_install_cmd::install_plugin_from_file,
commands::plugin_install_cmd::install_plugin_from_url,
commands::plugin_install_cmd::uninstall_plugin,
commands::plugin_install_cmd::list_installed_plugins,
commands::plugin_install_cmd::get_installed_plugin,
commands::plugin_install_cmd::is_plugin_installed,
// Plugin UI commands
commands::plugin_cmd::get_plugins_with_ui,
// Flow Monitor commands
commands::flow_monitor_cmd::query_flows,
commands::flow_monitor_cmd::get_flow_detail,
+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,
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.21.0",
"version": "0.22.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+39 -5
View File
@@ -1,3 +1,12 @@
/**
* 应用主入口组件
*
* 管理页面路由和全局状态
* 支持静态页面和动态插件页面路由
*
* _需求: 2.2, 3.2_
*/
import { useState, useEffect } from "react";
import { Sidebar } from "./components/Sidebar";
import { SettingsPage } from "./components/settings";
@@ -7,11 +16,20 @@ import { ConfigManagementPage } from "./components/config/ConfigManagementPage";
import { FlowMonitorPage } from "./pages";
import { ToolsPage } from "./components/tools/ToolsPage";
import { BrowserInterceptorTool } from "./components/tools/browser-interceptor/BrowserInterceptorTool";
import { MachineIdTool } from "./components/tools/machine-id/MachineIdTool";
import { AgentChatPage } from "./components/agent";
import { PluginUIRenderer } from "./components/plugins/PluginUIRenderer";
import { Toaster } from "./components/ui/sonner";
import { flowEventManager } from "./lib/flowEventManager";
/**
* 页面类型定义
*
* 支持静态页面和动态插件页面
* - 静态页面: 预定义的页面标识符
* - 动态插件页面: `plugin:${string}` 格式,如 "plugin:machine-id-tool"
*
* _需求: 2.2, 3.2_
*/
type Page =
| "provider-pool"
| "config-management"
@@ -20,8 +38,8 @@ type Page =
| "agent"
| "tools"
| "browser-interceptor"
| "machine-id"
| "settings";
| "settings"
| `plugin:${string}`;
function App() {
const [currentPage, setCurrentPage] = useState<Page>("api-server");
@@ -40,7 +58,25 @@ function App() {
}
}, [currentPage]);
/**
* 渲染当前页面
*
* 根据 currentPage 状态渲染对应的页面组件
* - 静态页面: 直接渲染对应组件
* - 动态插件页面: 使用 PluginUIRenderer 渲染
*
* _需求: 2.2, 3.2_
*/
const renderPage = () => {
// 检查是否为动态插件页面 (plugin:xxx 格式)
if (currentPage.startsWith("plugin:")) {
const pluginId = currentPage.slice(7); // 移除 "plugin:" 前缀
return (
<PluginUIRenderer pluginId={pluginId} onNavigate={setCurrentPage} />
);
}
// 静态页面路由
switch (currentPage) {
case "provider-pool":
return <ProviderPoolPage />;
@@ -56,8 +92,6 @@ function App() {
return <ToolsPage onNavigate={setCurrentPage} />;
case "browser-interceptor":
return <BrowserInterceptorTool onNavigate={setCurrentPage} />;
case "machine-id":
return <MachineIdTool onNavigate={setCurrentPage} />;
case "settings":
return <SettingsPage />;
default:
+11 -2
View File
@@ -9,6 +9,15 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 页面类型定义
*
* 支持静态页面和动态插件页面
* - 静态页面: 预定义的页面标识符
* - 动态插件页面: `plugin:${string}` 格式
*
* _需求: 2.2, 3.2_
*/
type Page =
| "provider-pool"
| "config-management"
@@ -17,8 +26,8 @@ type Page =
| "agent"
| "tools"
| "browser-interceptor"
| "machine-id"
| "settings";
| "settings"
| `plugin:${string}`;
interface SidebarProps {
currentPage: Page;
@@ -0,0 +1,475 @@
/**
* 插件安装对话框组件
*
* 支持从本地文件或 URL 安装插件,显示安装进度
* _需求: 1.1, 2.1, 3.1, 3.2, 3.3, 3.4_
*/
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import {
FolderOpen,
Link,
Loader2,
CheckCircle,
XCircle,
Download,
FileArchive,
} from "lucide-react";
import { Modal, ModalHeader, ModalBody, ModalFooter } from "@/components/Modal";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
/** 安装阶段 */
type InstallStage =
| "downloading"
| "validating"
| "extracting"
| "installing"
| "registering"
| "complete"
| "failed";
/** 安装进度事件 */
interface InstallProgress {
stage: InstallStage;
percent: number;
message: string;
}
/** 安装来源 */
interface InstallSource {
type: "local" | "url" | "github";
path?: string;
url?: string;
owner?: string;
repo?: string;
tag?: string;
}
/** 已安装插件信息 */
interface InstalledPlugin {
id: string;
name: string;
version: string;
description: string;
author: string | null;
install_path: string;
installed_at: string;
source: InstallSource;
enabled: boolean;
}
/** 安装结果 */
interface InstallResult {
success: boolean;
plugin: InstalledPlugin | null;
error: string | null;
}
interface PluginInstallDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
/** 初始 URL,用于一键安装 */
initialUrl?: string;
}
/** 获取阶段显示文本 */
function getStageText(stage: InstallStage): string {
switch (stage) {
case "downloading":
return "下载中";
case "validating":
return "验证中";
case "extracting":
return "解压中";
case "installing":
return "安装中";
case "registering":
return "注册中";
case "complete":
return "完成";
case "failed":
return "失败";
default:
return stage;
}
}
export function PluginInstallDialog({
isOpen,
onClose,
onSuccess,
initialUrl,
}: PluginInstallDialogProps) {
const [activeTab, setActiveTab] = useState<"file" | "url">("file");
const [filePath, setFilePath] = useState("");
const [url, setUrl] = useState("");
const [installing, setInstalling] = useState(false);
const [progress, setProgress] = useState<InstallProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<InstalledPlugin | null>(null);
const [autoInstallTriggered, setAutoInstallTriggered] = useState(false);
// 监听安装进度事件
useEffect(() => {
if (!isOpen) return;
const unlisten = listen<InstallProgress>(
"plugin-install-progress",
(event) => {
setProgress(event.payload);
},
);
return () => {
unlisten.then((fn) => fn());
};
}, [isOpen]);
// 处理 initialUrl - 自动切换到 URL tab 并填充
useEffect(() => {
if (isOpen && initialUrl && !autoInstallTriggered) {
setActiveTab("url");
setUrl(initialUrl);
setAutoInstallTriggered(true);
}
}, [isOpen, initialUrl, autoInstallTriggered]);
// 从 URL 安装
const handleInstallFromUrl = useCallback(async () => {
const currentUrl = url;
if (!currentUrl.trim()) {
setError("请输入插件包 URL");
return;
}
if (
!currentUrl.startsWith("http://") &&
!currentUrl.startsWith("https://")
) {
setError("URL 必须以 http:// 或 https:// 开头");
return;
}
setInstalling(true);
setError(null);
setProgress(null);
setResult(null);
try {
const installResult = await invoke<InstallResult>(
"install_plugin_from_url",
{ url: currentUrl },
);
if (installResult.success && installResult.plugin) {
setResult(installResult.plugin);
onSuccess();
} else {
setError(installResult.error || "安装失败");
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setInstalling(false);
}
}, [url, onSuccess]);
// 自动开始安装(当有 initialUrl 时)
useEffect(() => {
if (
isOpen &&
initialUrl &&
autoInstallTriggered &&
url === initialUrl &&
!installing &&
!result &&
!error
) {
// 延迟一点开始安装,让用户看到 UI
const timer = setTimeout(() => {
handleInstallFromUrl();
}, 500);
return () => clearTimeout(timer);
}
}, [
isOpen,
initialUrl,
autoInstallTriggered,
url,
installing,
result,
error,
handleInstallFromUrl,
]);
// 重置状态
const resetState = () => {
setFilePath("");
setUrl("");
setInstalling(false);
setProgress(null);
setError(null);
setResult(null);
setAutoInstallTriggered(false);
};
// 关闭对话框
const handleClose = () => {
if (installing) return; // 安装中不允许关闭
resetState();
onClose();
};
// 选择本地文件
const handleSelectFile = async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "插件包", extensions: ["zip", "tar.gz", "tgz"] }],
});
if (selected) {
setFilePath(selected as string);
setError(null);
}
} catch (e) {
console.error("选择文件失败:", e);
}
};
// 从本地文件安装
const handleInstallFromFile = async () => {
if (!filePath.trim()) {
setError("请选择插件包文件");
return;
}
setInstalling(true);
setError(null);
setProgress(null);
setResult(null);
try {
const installResult = await invoke<InstallResult>(
"install_plugin_from_file",
{ filePath },
);
if (installResult.success && installResult.plugin) {
setResult(installResult.plugin);
onSuccess();
} else {
setError(installResult.error || "安装失败");
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setInstalling(false);
}
};
// 渲染进度显示
const renderProgress = () => {
if (!progress) return null;
const isComplete = progress.stage === "complete";
const isFailed = progress.stage === "failed";
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
{isComplete ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : isFailed ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<Loader2 className="h-4 w-4 animate-spin" />
)}
{getStageText(progress.stage)}
</span>
<span>{progress.percent}%</span>
</div>
<Progress value={progress.percent} />
<p className="text-xs text-muted-foreground">{progress.message}</p>
</div>
);
};
// 渲染安装结果
const renderResult = () => {
if (!result) return null;
return (
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950/30">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-500 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium text-green-700 dark:text-green-300">
安装成功
</h4>
<div className="mt-2 space-y-1 text-sm text-green-600 dark:text-green-400">
<p>
<span className="font-medium">插件名称:</span>
{result.name}
</p>
<p>
<span className="font-medium">版本:</span>
{result.version}
</p>
{result.description && (
<p>
<span className="font-medium">描述:</span>
{result.description}
</p>
)}
</div>
</div>
</div>
</div>
);
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
maxWidth="max-w-md"
closeOnOverlayClick={!installing}
>
<ModalHeader>安装插件</ModalHeader>
<ModalBody>
{/* 安装结果显示 */}
{result ? (
renderResult()
) : (
<>
{/* 安装方式选择 */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "file" | "url")}
>
<TabsList className="w-full">
<TabsTrigger value="file" className="flex-1">
<FileArchive className="h-4 w-4 mr-2" />
本地文件
</TabsTrigger>
<TabsTrigger value="url" className="flex-1">
<Link className="h-4 w-4 mr-2" />
URL 下载
</TabsTrigger>
</TabsList>
{/* 本地文件安装 */}
<TabsContent value="file" className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">
选择插件包文件
</label>
<div className="flex gap-2">
<Input
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="选择 .zip 或 .tar.gz 文件..."
disabled={installing}
className="flex-1"
/>
<Button
variant="outline"
onClick={handleSelectFile}
disabled={installing}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
支持 .zip 和 .tar.gz 格式的插件包
</p>
</div>
</TabsContent>
{/* URL 安装 */}
<TabsContent value="url" className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">
插件包 URL
</label>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com/.../releases/download/..."
disabled={installing}
/>
<p className="mt-1 text-xs text-muted-foreground">
支持 GitHub Releases 或其他直接下载链接
</p>
</div>
</TabsContent>
</Tabs>
{/* 进度显示 */}
{installing && progress && (
<div className="mt-4">{renderProgress()}</div>
)}
{/* 错误显示 */}
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400">
<div className="flex items-start gap-2">
<XCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
</div>
)}
</>
)}
</ModalBody>
<ModalFooter>
{result ? (
<Button onClick={handleClose}>完成</Button>
) : (
<>
<Button
variant="outline"
onClick={handleClose}
disabled={installing}
>
取消
</Button>
<Button
onClick={
activeTab === "file"
? handleInstallFromFile
: handleInstallFromUrl
}
disabled={
installing ||
(activeTab === "file" ? !filePath.trim() : !url.trim())
}
>
{installing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
安装中...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
安装
</>
)}
</Button>
</>
)}
</ModalFooter>
</Modal>
);
}
+266 -8
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
Puzzle,
@@ -12,8 +12,16 @@ import {
Clock,
ChevronDown,
ChevronUp,
Plus,
Package,
Download,
Cpu,
} from "lucide-react";
import { BinaryComponents } from "@/components/extensions/BinaryComponents";
import { PluginInstallDialog } from "./PluginInstallDialog";
import { PluginUninstallDialog } from "./PluginUninstallDialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
interface PluginState {
name: string;
@@ -50,34 +58,119 @@ interface PluginServiceStatus {
plugins_dir: string;
}
/** 安装来源 */
interface InstallSource {
type: "local" | "url" | "github";
path?: string;
url?: string;
owner?: string;
repo?: string;
tag?: string;
}
/** 已安装插件信息(通过安装器安装的) */
interface InstalledPlugin {
id: string;
name: string;
version: string;
description: string;
author: string | null;
install_path: string;
installed_at: string;
source: InstallSource;
enabled: boolean;
}
/**
* 推荐插件配置
*/
interface RecommendedPlugin {
/** 插件 ID */
id: string;
/** 插件名称 */
name: string;
/** 插件描述 */
description: string;
/** 图标组件 */
icon: React.ComponentType<{ className?: string }>;
/** 下载 URL */
downloadUrl: string;
}
/**
* 推荐插件列表
*/
const recommendedPlugins: RecommendedPlugin[] = [
{
id: "machine-id-tool",
name: "机器码管理工具",
description: "查看、修改和管理系统机器码,支持跨平台操作",
icon: Cpu,
// 插件包从 ProxyCast release 下载
downloadUrl:
"https://github.com/aiclientproxy/proxycast/releases/latest/download/machine-id-tool.zip",
},
];
export function PluginManager() {
const [status, setStatus] = useState<PluginServiceStatus | null>(null);
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [installedPlugins, setInstalledPlugins] = useState<InstalledPlugin[]>(
[],
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const fetchData = async () => {
// 对话框状态
const [showInstallDialog, setShowInstallDialog] = useState(false);
const [pluginToUninstall, setPluginToUninstall] =
useState<InstalledPlugin | null>(null);
const [pendingInstallUrl, setPendingInstallUrl] = useState<string | null>(
null,
);
const fetchData = useCallback(async () => {
try {
setLoading(true);
const [serviceStatus, pluginList] = await Promise.all([
const [serviceStatus, pluginList, installedList] = await Promise.all([
invoke<PluginServiceStatus>("get_plugin_status"),
invoke<PluginInfo[]>("get_plugins"),
invoke<InstalledPlugin[]>("list_installed_plugins").catch(() => []),
]);
setStatus(serviceStatus);
setPlugins(pluginList);
setInstalledPlugins(installedList);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// 处理一键安装
const handleQuickInstall = useCallback((downloadUrl: string) => {
setPendingInstallUrl(downloadUrl);
setShowInstallDialog(true);
}, []);
// 处理安装成功
const handleInstallSuccess = useCallback(() => {
fetchData();
setPendingInstallUrl(null);
}, [fetchData]);
// 过滤出未安装的推荐插件
const installedPluginIds = new Set(installedPlugins.map((p) => p.id));
const uninstalledRecommendedPlugins = recommendedPlugins.filter(
(plugin) => !installedPluginIds.has(plugin.id),
);
const handleTogglePlugin = async (name: string, currentEnabled: boolean) => {
try {
if (currentEnabled) {
@@ -161,6 +254,14 @@ export function PluginManager() {
插件系统
</h3>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => setShowInstallDialog(true)}
className="gap-1"
>
<Plus className="h-4 w-4" />
安装插件
</Button>
<button
onClick={handleReloadPlugins}
className="p-1 hover:bg-muted rounded"
@@ -178,11 +279,17 @@ export function PluginManager() {
)}
{status && (
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center">
<div className="text-2xl font-bold">{status.plugin_count}</div>
<div className="text-xs text-muted-foreground">已加载插件</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">
{installedPlugins.length}
</div>
<div className="text-xs text-muted-foreground">已安装插件</div>
</div>
<div className="text-center">
<div
className="text-sm font-mono truncate"
@@ -197,17 +304,82 @@ export function PluginManager() {
)}
</div>
{/* 推荐插件 */}
{uninstalledRecommendedPlugins.length > 0 && (
<div className="rounded-lg border bg-card">
<div className="p-4 border-b">
<h4 className="font-semibold flex items-center gap-2">
<Download className="h-4 w-4" />
推荐插件
</h4>
</div>
<div className="divide-y">
{uninstalledRecommendedPlugins.map((plugin) => (
<div key={plugin.id} className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<plugin.icon className="h-5 w-5 text-primary" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{plugin.name}</span>
<Badge variant="outline" className="text-xs">
推荐
</Badge>
</div>
<div className="text-sm text-muted-foreground">
{plugin.description}
</div>
</div>
</div>
<Button
size="sm"
onClick={() => handleQuickInstall(plugin.downloadUrl)}
className="gap-1"
>
<Download className="h-4 w-4" />
一键安装
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* 已安装插件列表(通过安装器安装的) */}
{installedPlugins.length > 0 && (
<div className="rounded-lg border bg-card">
<div className="p-4 border-b flex items-center justify-between">
<h4 className="font-semibold flex items-center gap-2">
<Package className="h-4 w-4" />
已安装插件包
</h4>
</div>
<div className="divide-y">
{installedPlugins.map((plugin) => (
<InstalledPluginItem
key={plugin.id}
plugin={plugin}
onUninstall={() => setPluginToUninstall(plugin)}
/>
))}
</div>
</div>
)}
{/* 插件列表 */}
<div className="rounded-lg border bg-card">
<div className="p-4 border-b">
<h4 className="font-semibold">已安装插件</h4>
<h4 className="font-semibold">已加载插件</h4>
</div>
{plugins.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Puzzle className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>暂无已安装的插件</p>
<p className="text-sm mt-1">将插件放入插件目录即可自动加载</p>
<p>暂无已加载的插件</p>
<p className="text-sm mt-1">点击"安装插件"按钮添加新插件</p>
</div>
) : (
<div className="divide-y">
@@ -232,6 +404,25 @@ export function PluginManager() {
</div>
)}
</div>
{/* 安装对话框 */}
<PluginInstallDialog
isOpen={showInstallDialog}
onClose={() => {
setShowInstallDialog(false);
setPendingInstallUrl(null);
}}
onSuccess={handleInstallSuccess}
initialUrl={pendingInstallUrl || undefined}
/>
{/* 卸载确认对话框 */}
<PluginUninstallDialog
isOpen={pluginToUninstall !== null}
plugin={pluginToUninstall}
onClose={() => setPluginToUninstall(null)}
onSuccess={fetchData}
/>
</div>
);
}
@@ -375,3 +566,70 @@ function PluginItem({
}
export default PluginManager;
/** 已安装插件项组件 */
interface InstalledPluginItemProps {
plugin: InstalledPlugin;
onUninstall: () => void;
}
function InstalledPluginItem({
plugin,
onUninstall,
}: InstalledPluginItemProps) {
// 获取安装来源显示文本
const getSourceText = (source: InstallSource): string => {
switch (source.type) {
case "local":
return `本地文件: ${source.path?.split("/").pop() || "未知"}`;
case "url":
return `URL: ${source.url?.split("/").pop() || "未知"}`;
case "github":
return `GitHub: ${source.owner}/${source.repo}@${source.tag}`;
default:
return "未知来源";
}
};
return (
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{plugin.name}</span>
<span className="text-xs text-muted-foreground">
v{plugin.version}
</span>
{plugin.enabled ? (
<span className="flex items-center gap-1 text-xs text-green-600">
<CheckCircle className="h-3 w-3" />
已启用
</span>
) : (
<span className="flex items-center gap-1 text-xs text-gray-400">
<PowerOff className="h-3 w-3" />
已禁用
</span>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
{plugin.description || "无描述"}
</div>
<div className="text-xs text-muted-foreground mt-1">
{getSourceText(plugin.source)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onUninstall}
className="p-2 rounded bg-red-100 text-red-600 hover:bg-red-200"
title="卸载插件"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,154 @@
/**
* @file PluginUIRenderer 单元测试
* @description 测试插件 UI 渲染器组件
* @module components/plugins/PluginUIRenderer.test
*
* _需求: 3.2_
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { PluginUIRenderer, type Page } from "./PluginUIRenderer";
// Mock MachineIdTool 组件
vi.mock("@/components/tools/machine-id/MachineIdTool", () => ({
MachineIdTool: (_props: { onNavigate: (page: Page) => void }) => (
<div data-testid="machine-id-tool">MachineIdTool Mock</div>
),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
AlertCircle: () => <span data-testid="alert-circle-icon">AlertCircle</span>,
Package: () => <span data-testid="package-icon">Package</span>,
}));
describe("PluginUIRenderer", () => {
const mockNavigate = vi.fn();
beforeEach(() => {
mockNavigate.mockClear();
});
describe("内置插件组件渲染", () => {
it("应该正确渲染 machine-id-tool 插件", () => {
const { container } = renderComponent(
<PluginUIRenderer
pluginId="machine-id-tool"
onNavigate={mockNavigate}
/>,
);
// 验证 MachineIdTool 组件被渲染
const machineIdTool = container.querySelector(
'[data-testid="machine-id-tool"]',
);
expect(machineIdTool).not.toBeNull();
expect(machineIdTool?.textContent).toBe("MachineIdTool Mock");
});
});
describe("未知插件处理", () => {
it("应该为未知插件显示 '插件未找到' 提示", () => {
const { container } = renderComponent(
<PluginUIRenderer
pluginId="unknown-plugin"
onNavigate={mockNavigate}
/>,
);
// 验证显示插件未找到提示
expect(container.textContent).toContain("插件未找到");
expect(container.textContent).toContain("unknown-plugin");
expect(container.textContent).toContain("未安装或不存在");
});
it("应该为空字符串 pluginId 显示 '插件未找到' 提示", () => {
const { container } = renderComponent(
<PluginUIRenderer pluginId="" onNavigate={mockNavigate} />,
);
// 验证显示插件未找到提示
expect(container.textContent).toContain("插件未找到");
});
it("应该为随机 pluginId 显示 '插件未找到' 提示", () => {
const randomPluginId = `random-plugin-${Date.now()}`;
const { container } = renderComponent(
<PluginUIRenderer
pluginId={randomPluginId}
onNavigate={mockNavigate}
/>,
);
// 验证显示插件未找到提示,并包含插件 ID
expect(container.textContent).toContain("插件未找到");
expect(container.textContent).toContain(randomPluginId);
});
});
describe("插件 ID 大小写敏感性", () => {
it("应该区分大小写 - 'Machine-Id-Tool' 应该显示未找到", () => {
const { container } = renderComponent(
<PluginUIRenderer
pluginId="Machine-Id-Tool"
onNavigate={mockNavigate}
/>,
);
// 验证大小写不匹配时显示未找到
expect(container.textContent).toContain("插件未找到");
});
it("应该区分大小写 - 'MACHINE-ID-TOOL' 应该显示未找到", () => {
const { container } = renderComponent(
<PluginUIRenderer
pluginId="MACHINE-ID-TOOL"
onNavigate={mockNavigate}
/>,
);
// 验证大小写不匹配时显示未找到
expect(container.textContent).toContain("插件未找到");
});
});
});
/**
* 简单的渲染辅助函数
* 使用 jsdom 环境渲染 React 组件
*/
function renderComponent(element: React.ReactElement) {
const container = document.createElement("div");
document.body.appendChild(container);
// 使用 React 18 的 createRoot API
const root = createRoot(container);
// 临时禁用 console.error 来抑制 act 警告
const originalError = console.error;
console.error = (...args: unknown[]) => {
if (typeof args[0] === "string" && args[0].includes("act(...)")) {
return;
}
originalError.apply(console, args);
};
act(() => {
root.render(element);
});
// 恢复 console.error
console.error = originalError;
return {
container,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
+138
View File
@@ -0,0 +1,138 @@
/**
* 插件 UI 渲染器组件
*
* 根据 pluginId 渲染对应的插件 UI 组件
* 支持内置插件组件映射和错误处理
*
* _需求: 3.2_
*/
import React from "react";
import { AlertCircle, Package } from "lucide-react";
import { MachineIdTool } from "@/components/tools/machine-id/MachineIdTool";
/**
* 页面类型定义
* 支持静态页面和动态插件页面
*/
export type Page =
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "browser-interceptor"
| "settings"
| `plugin:${string}`;
/**
* PluginUIRenderer 组件属性
*/
interface PluginUIRendererProps {
/** 插件 ID */
pluginId: string;
/** 页面导航回调 */
onNavigate: (page: Page) => void;
}
/**
* 插件 UI 加载错误组件
*
* 当插件 UI 组件加载失败时显示友好的错误提示
*/
function PluginUIError({
pluginId,
error,
}: {
pluginId: string;
error: string;
}) {
return (
<div className="flex flex-col items-center justify-center h-96 space-y-4">
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-full">
<AlertCircle className="w-12 h-12 text-red-500" />
</div>
<div className="text-center space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
插件 UI 加载失败
</h2>
<p className="text-gray-600 dark:text-gray-400">
无法加载插件 "{pluginId}" 的用户界面
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">{error}</p>
</div>
</div>
);
}
/**
* 插件未找到组件
*
* 当请求的插件不存在时显示提示
*/
function PluginNotFound({ pluginId }: { pluginId: string }) {
return (
<div className="flex flex-col items-center justify-center h-96 space-y-4">
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-full">
<Package className="w-12 h-12 text-gray-400" />
</div>
<div className="text-center space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
插件未找到
</h2>
<p className="text-gray-600 dark:text-gray-400">
插件 "{pluginId}" 未安装或不存在
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
请检查插件是否已正确安装
</p>
</div>
</div>
);
}
/**
* 内置插件组件映射
*
* 将插件 ID 映射到对应的 React 组件
* 目前支持 machine-id-tool 插件
*/
const builtinPluginComponents: Record<
string,
React.ComponentType<{ onNavigate: (page: Page) => void }>
> = {
"machine-id-tool": MachineIdTool,
};
/**
* 插件 UI 渲染器
*
* 根据 pluginId 渲染对应的插件 UI 组件
* - 对于内置插件,直接渲染对应的 React 组件
* - 对于未知插件,显示错误提示
*
* @param pluginId - 插件 ID
* @param onNavigate - 页面导航回调
*/
export function PluginUIRenderer({
pluginId,
onNavigate,
}: PluginUIRendererProps) {
// 查找内置插件组件
const Component = builtinPluginComponents[pluginId];
if (Component) {
try {
return <Component onNavigate={onNavigate} />;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "未知错误";
return <PluginUIError pluginId={pluginId} error={errorMessage} />;
}
}
// 插件未找到
return <PluginNotFound pluginId={pluginId} />;
}
export default PluginUIRenderer;
@@ -0,0 +1,145 @@
/**
* 插件卸载确认对话框组件
*
* 显示卸载确认信息,确认后调用卸载命令
* _需求: 4.1, 4.2, 4.3_
*/
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import { Modal, ModalBody, ModalFooter } from "@/components/Modal";
import { Button } from "@/components/ui/button";
/** 已安装插件信息 */
interface InstalledPlugin {
id: string;
name: string;
version: string;
description: string;
author: string | null;
install_path: string;
installed_at: string;
enabled: boolean;
}
interface PluginUninstallDialogProps {
isOpen: boolean;
plugin: InstalledPlugin | null;
onClose: () => void;
onSuccess: () => void;
}
export function PluginUninstallDialog({
isOpen,
plugin,
onClose,
onSuccess,
}: PluginUninstallDialogProps) {
const [uninstalling, setUninstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
// 关闭对话框
const handleClose = () => {
if (uninstalling) return;
setError(null);
onClose();
};
// 执行卸载
const handleUninstall = async () => {
if (!plugin) return;
setUninstalling(true);
setError(null);
try {
await invoke<boolean>("uninstall_plugin", { pluginId: plugin.id });
onSuccess();
handleClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setUninstalling(false);
}
};
if (!plugin) return null;
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
maxWidth="max-w-sm"
showCloseButton={false}
closeOnOverlayClick={!uninstalling}
>
<ModalBody>
<div className="flex items-start gap-4">
<div className="mt-0.5 text-red-500">
<AlertTriangle className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">确认卸载插件</h3>
<div className="mt-2 space-y-2 text-sm text-muted-foreground">
<p>
确定要卸载插件{" "}
<span className="font-medium text-foreground">
{plugin.name}
</span>{" "}
吗?
</p>
<p>此操作将删除插件文件和相关配置,无法撤销。</p>
</div>
{/* 插件信息 */}
<div className="mt-4 rounded-lg bg-muted p-3 text-sm">
<div className="space-y-1">
<p>
<span className="text-muted-foreground">版本:</span>
{plugin.version}
</p>
{plugin.description && (
<p>
<span className="text-muted-foreground">描述:</span>
{plugin.description}
</p>
)}
</div>
</div>
{/* 错误显示 */}
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400">
{error}
</div>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant="outline" onClick={handleClose} disabled={uninstalling}>
取消
</Button>
<Button
variant="destructive"
onClick={handleUninstall}
disabled={uninstalling}
>
{uninstalling ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
卸载中...
</>
) : (
<>
<Trash2 className="h-4 w-4 mr-2" />
确认卸载
</>
)}
</Button>
</ModalFooter>
</Modal>
);
}
+47
View File
@@ -0,0 +1,47 @@
# 插件管理组件
插件系统的前端 UI 组件,提供插件安装、卸载和管理功能。
## 文件索引
| 文件 | 说明 |
|------|------|
| `PluginManager.tsx` | 插件管理主组件,显示插件列表和状态 |
| `PluginInstallDialog.tsx` | 插件安装对话框,支持本地文件和 URL 安装 |
| `PluginUninstallDialog.tsx` | 插件卸载确认对话框 |
| `PluginUIRenderer.tsx` | 插件 UI 渲染器,根据 pluginId 渲染对应的插件 UI |
| `index.ts` | 模块导出 |
## 功能说明
### PluginManager
- 显示插件系统状态概览
- 列出已加载的插件和已安装的插件包
- 提供安装/卸载入口
- 支持启用/禁用插件
### PluginInstallDialog
- 支持从本地文件安装(.zip, .tar.gz)
- 支持从 URL 下载安装(GitHub Releases 等)
- 显示安装进度(下载、验证、解压、安装、注册)
- 显示安装结果
### PluginUninstallDialog
- 显示插件信息确认
- 调用后端卸载命令
- 刷新插件列表
### PluginUIRenderer
- 根据 pluginId 渲染对应的插件 UI 组件
- 支持内置插件组件映射 (machine-id-tool -> MachineIdTool)
- 显示友好的错误提示(插件未找到、加载失败)
- 导出 Page 类型定义,支持动态插件路由
## 相关需求
- 需求 1.1: 本地文件安装
- 需求 2.1: URL 安装
- 需求 3.1-3.4: 安装进度显示
- 需求 3.2: 插件 UI 渲染
- 需求 4.1-4.3: 卸载功能
- 需求 6.1: 插件列表显示
+4
View File
@@ -1,2 +1,6 @@
export { PluginManager } from "./PluginManager";
export { PluginInstallDialog } from "./PluginInstallDialog";
export { PluginUninstallDialog } from "./PluginUninstallDialog";
export { PluginUIRenderer } from "./PluginUIRenderer";
export type { Page } from "./PluginUIRenderer";
export { default } from "./PluginManager";
+337 -64
View File
@@ -1,5 +1,16 @@
import React, { useState } from "react";
import { Globe, Plus, Settings, Activity, Cpu } from "lucide-react";
/**
* 工具箱页面组件
*
* 显示所有可用工具,包括内置工具和插件工具
* 支持从插件系统动态获取工具列表
* 支持推荐插件一键安装
*
* _需求: 1.2, 2.1, 2.2_
*/
import React, { useState, useEffect, useCallback } from "react";
import { Package, Loader2, Download, type LucideIcon } from "lucide-react";
import * as LucideIcons from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -9,19 +20,57 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { getPluginsForSurface, type PluginUIInfo } from "@/lib/api/pluginUI";
import { PluginInstallDialog } from "@/components/plugins/PluginInstallDialog";
/**
* 页面类型定义
*
* 支持静态页面和动态插件页面
* - 静态页面: 预定义的页面标识符
* - 动态插件页面: `plugin:${string}` 格式,如 "plugin:machine-id-tool"
*
* _需求: 2.2, 3.2_
*/
type Page =
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "browser-interceptor"
| "settings"
| `plugin:${string}`;
interface ToolsPageProps {
onNavigate: (
page:
| "provider-pool"
| "config-management"
| "api-server"
| "flow-monitor"
| "tools"
| "browser-interceptor"
| "machine-id"
| "settings",
) => void;
/**
* 页面导航回调
* 支持静态页面和动态插件页面
*/
onNavigate: (page: Page) => void;
}
/**
* 动态工具卡片数据结构
*/
interface DynamicToolCard {
/** 工具 ID */
id: string;
/** 工具标题 */
title: string;
/** 工具描述 */
description: string;
/** 图标名称 (Lucide 图标) */
icon: string;
/** 工具来源: builtin (内置) 或 plugin (插件) */
source: "builtin" | "plugin";
/** 插件 ID (仅插件工具) */
pluginId?: string;
/** 是否禁用 */
disabled?: boolean;
/** 状态文本 */
status?: string;
}
interface ToolCardProps {
@@ -31,8 +80,46 @@ interface ToolCardProps {
status?: string;
disabled?: boolean;
onClick?: () => void;
source?: "builtin" | "plugin";
}
/**
* 推荐插件配置
*/
interface RecommendedPlugin {
/** 插件 ID */
id: string;
/** 插件名称 */
name: string;
/** 插件描述 */
description: string;
/** 图标名称 */
icon: string;
/** 下载 URL */
downloadUrl: string;
}
/**
* 根据图标名称获取 Lucide 图标组件
*
* @param iconName - 图标名称 (如 "Cpu", "Globe")
* @returns Lucide 图标组件
*/
function getLucideIcon(iconName: string): LucideIcon {
// 将图标名称转换为 PascalCase
const pascalCase = iconName
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
// 从 LucideIcons 中获取图标
const Icon = (LucideIcons as any)[pascalCase] as LucideIcon | undefined;
return Icon || Package;
}
/**
* 工具卡片组件
*/
function ToolCard({
title,
description,
@@ -40,6 +127,7 @@ function ToolCard({
status,
disabled = false,
onClick,
source,
}: ToolCardProps) {
return (
<Card
@@ -51,14 +139,20 @@ function ToolCard({
<div className="p-2 bg-primary/10 rounded-lg">{icon}</div>
<div>
<CardTitle className="text-lg">{title}</CardTitle>
{status && (
<Badge
variant={status === "运行中" ? "default" : "secondary"}
className="mt-1"
>
{status}
</Badge>
)}
<div className="flex items-center gap-2 mt-1">
{status && (
<Badge
variant={status === "运行中" ? "default" : "secondary"}
>
{status}
</Badge>
)}
{source === "plugin" && (
<Badge variant="outline" className="text-xs">
插件
</Badge>
)}
</div>
</div>
</div>
</div>
@@ -81,15 +175,153 @@ function ToolCard({
);
}
export function ToolsPage({ onNavigate }: ToolsPageProps) {
const [interceptorEnabled] = useState(false); // TODO: 从状态管理中获取
/**
* 内置工具列表
*/
const builtinTools: DynamicToolCard[] = [
{
id: "browser-interceptor",
title: "浏览器拦截器",
description: "拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器",
icon: "Globe",
source: "builtin",
status: "已停止", // TODO: 从状态管理中获取
},
];
const handleBrowserInterceptorClick = () => {
onNavigate("browser-interceptor");
/**
* 占位工具列表 (敬请期待)
*/
const placeholderTools: DynamicToolCard[] = [
{
id: "network-monitor",
title: "网络监控工具",
description: "监控和分析网络请求,提供详细的流量分析",
icon: "Activity",
source: "builtin",
disabled: true,
},
{
id: "config-sync",
title: "配置同步工具",
description: "在多个设备间同步 ProxyCast 配置",
icon: "Settings",
source: "builtin",
disabled: true,
},
{
id: "more-tools",
title: "更多工具",
description: "更多实用工具正在开发中...",
icon: "Plus",
source: "builtin",
disabled: true,
},
];
/**
* 推荐插件列表
*/
const recommendedPlugins: RecommendedPlugin[] = [
{
id: "machine-id-tool",
name: "机器码管理工具",
description: "查看、修改和管理系统机器码,支持跨平台操作",
icon: "Cpu",
// 插件包从 ProxyCast release 下载
downloadUrl:
"https://github.com/aiclientproxy/proxycast/releases/latest/download/machine-id-tool.zip",
},
];
export function ToolsPage({ onNavigate }: ToolsPageProps) {
const [pluginTools, setPluginTools] = useState<DynamicToolCard[]>([]);
const [loading, setLoading] = useState(true);
const [showInstallDialog, setShowInstallDialog] = useState(false);
const [pendingInstallUrl, setPendingInstallUrl] = useState<string | null>(
null,
);
const [installedPluginIds, setInstalledPluginIds] = useState<Set<string>>(
new Set(),
);
// 加载插件工具和已安装插件列表
const loadPluginTools = useCallback(async () => {
try {
const plugins = await getPluginsForSurface("tools");
const tools: DynamicToolCard[] = plugins.map((plugin: PluginUIInfo) => ({
id: `plugin:${plugin.pluginId}`,
title: plugin.name,
description: plugin.description,
icon: plugin.icon || "Package",
source: "plugin" as const,
pluginId: plugin.pluginId,
}));
setPluginTools(tools);
// 更新已安装插件 ID 集合
const installedIds = new Set(
plugins.map((p: PluginUIInfo) => p.pluginId),
);
setInstalledPluginIds(installedIds);
} catch (error) {
console.error("加载插件工具失败:", error);
} finally {
setLoading(false);
}
}, []);
// 从插件系统获取工具列表
useEffect(() => {
loadPluginTools();
}, [loadPluginTools]);
// 处理安装成功
const handleInstallSuccess = useCallback(() => {
loadPluginTools();
setPendingInstallUrl(null);
}, [loadPluginTools]);
// 处理一键安装
const handleQuickInstall = useCallback((downloadUrl: string) => {
setPendingInstallUrl(downloadUrl);
setShowInstallDialog(true);
}, []);
// 合并内置工具和插件工具
const allTools = [...builtinTools, ...pluginTools, ...placeholderTools];
const activeToolsCount = builtinTools.length + pluginTools.length;
// 过滤出未安装的推荐插件
const uninstalledRecommendedPlugins = recommendedPlugins.filter(
(plugin) => !installedPluginIds.has(plugin.id),
);
/**
* 处理工具卡片点击
*/
const handleToolClick = (tool: DynamicToolCard) => {
if (tool.disabled) return;
if (tool.source === "plugin" && tool.pluginId) {
// 插件工具: 导航到 plugin:xxx 页面
onNavigate(`plugin:${tool.pluginId}`);
} else {
// 内置工具: 导航到对应页面
onNavigate(tool.id as Page);
}
};
const handleMachineIdClick = () => {
onNavigate("machine-id");
/**
* 渲染工具图标
*/
const renderIcon = (iconName: string, disabled?: boolean) => {
const Icon = getLucideIcon(iconName);
return (
<Icon
className={`w-6 h-6 ${disabled ? "text-muted-foreground" : "text-primary"}`}
/>
);
};
return (
@@ -101,46 +333,25 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) {
ProxyCast 提供的实用工具集合
</p>
</div>
<Badge variant="outline">2 个工具</Badge>
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
<Badge variant="outline">{activeToolsCount} 个工具</Badge>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<ToolCard
title="浏览器拦截器"
description="拦截桌面应用的浏览器启动,支持手动复制 URL 到指纹浏览器"
icon={<Globe className="w-6 h-6 text-primary" />}
status={interceptorEnabled ? "运行中" : "已停止"}
onClick={handleBrowserInterceptorClick}
/>
<ToolCard
title="机器码管理工具"
description="查看、修改和管理系统机器码,支持跨平台操作和备份恢复"
icon={<Cpu className="w-6 h-6 text-primary" />}
onClick={handleMachineIdClick}
/>
{/* 未来可以添加更多工具 */}
<ToolCard
title="网络监控工具"
description="监控和分析网络请求,提供详细的流量分析"
icon={<Activity className="w-6 h-6 text-muted-foreground" />}
disabled
/>
<ToolCard
title="配置同步工具"
description="在多个设备间同步 ProxyCast 配置"
icon={<Settings className="w-6 h-6 text-muted-foreground" />}
disabled
/>
<ToolCard
title="更多工具"
description="更多实用工具正在开发中..."
icon={<Plus className="w-6 h-6 text-muted-foreground" />}
disabled
/>
{allTools.map((tool) => (
<ToolCard
key={tool.id}
title={tool.title}
description={tool.description}
icon={renderIcon(tool.icon, tool.disabled)}
status={tool.status}
disabled={tool.disabled}
source={tool.source}
onClick={() => handleToolClick(tool)}
/>
))}
</div>
<div className="mt-8 p-6 bg-muted/30 rounded-lg">
@@ -149,8 +360,70 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) {
工具箱是 ProxyCast
的扩展功能模块,提供各种实用工具来增强您的使用体验。
每个工具都经过精心设计,旨在解决特定的使用场景和需求。
{pluginTools.length > 0 && (
<span className="block mt-2">
当前已安装 {pluginTools.length} 个插件工具。
</span>
)}
</p>
</div>
{/* 推荐插件区域 */}
{uninstalledRecommendedPlugins.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">推荐插件</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{uninstalledRecommendedPlugins.map((plugin) => {
const Icon = getLucideIcon(plugin.icon);
return (
<Card
key={plugin.id}
className="border-dashed border-2 border-primary/30 bg-primary/5"
>
<CardHeader className="pb-3">
<div className="flex items-center space-x-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon className="w-6 h-6 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{plugin.name}</CardTitle>
<Badge variant="outline" className="text-xs mt-1">
推荐安装
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<CardDescription className="text-sm text-muted-foreground mb-4">
{plugin.description}
</CardDescription>
<Button
variant="default"
size="sm"
className="w-full"
onClick={() => handleQuickInstall(plugin.downloadUrl)}
>
<Download className="w-4 h-4 mr-2" />
一键安装
</Button>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{/* 插件安装对话框 */}
<PluginInstallDialog
isOpen={showInstallDialog}
onClose={() => {
setShowInstallDialog(false);
setPendingInstallUrl(null);
}}
onSuccess={handleInstallSuccess}
initialUrl={pendingInstallUrl || undefined}
/>
</div>
);
}
@@ -16,10 +16,11 @@ interface MachineIdToolProps {
| "config-management"
| "api-server"
| "flow-monitor"
| "agent"
| "tools"
| "browser-interceptor"
| "machine-id"
| "settings",
| "settings"
| `plugin:${string}`,
) => void;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Alert 组件
*
* 用于显示重要信息、警告或错误提示
*/
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
+7 -2
View File
@@ -7,6 +7,7 @@ interface SelectContextType {
onValueChange: (value: string) => void;
open: boolean;
setOpen: (open: boolean) => void;
disabled: boolean;
}
const SelectContext = createContext<SelectContextType | undefined>(undefined);
@@ -15,6 +16,7 @@ interface SelectProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
children: React.ReactNode;
}
@@ -22,6 +24,7 @@ const Select: React.FC<SelectProps> = ({
value,
defaultValue,
onValueChange,
disabled = false,
children,
}) => {
const [internalValue, setInternalValue] = useState(defaultValue || "");
@@ -37,6 +40,7 @@ const Select: React.FC<SelectProps> = ({
onValueChange: handleValueChange,
open,
setOpen,
disabled,
}}
>
<div className="relative">{children}</div>
@@ -56,16 +60,17 @@ const SelectTrigger: React.FC<SelectTriggerProps> = ({
const context = useContext(SelectContext);
if (!context) throw new Error("SelectTrigger must be used within Select");
const { open, setOpen } = context;
const { open, setOpen, disabled } = context;
return (
<button
type="button"
disabled={disabled}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-gray-300 bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onClick={() => setOpen(!open)}
onClick={() => !disabled && setOpen(!open)}
>
{children}
<ChevronDown className="h-4 w-4 opacity-50" />
+10
View File
@@ -10,8 +10,18 @@
## 文件索引
- `api/` - API 调用封装
- `pluginUI.ts` - 插件 UI API(Requirements 3.1)
- `errors/` - 错误处理模块
- `playwrightErrors.ts` - Playwright 登录错误处理(Requirements 5.1, 5.2, 5.3, 5.4)
- `plugin-ui/` - 插件 UI 系统(基于 A2UI 设计理念)
- `types.ts` - 类型定义
- `ComponentRegistry.ts` - 组件注册表
- `DataStore.ts` - 数据存储
- `SurfaceManager.ts` - Surface 管理器
- `PluginUIRenderer.tsx` - 核心渲染器
- `PluginUIContainer.tsx` - 容器组件
- `usePluginUI.ts` - React Hook
- `components/` - 标准组件实现
- `tauri/` - Tauri 命令封装
- `utils/` - 通用工具函数
- `flowEventManager.ts` - 流量事件管理器
+55
View File
@@ -0,0 +1,55 @@
/**
* 插件 UI API
*
* 提供插件 UI 注册系统的前端 API 调用
* 用于获取带有 UI 配置的已安装插件列表
*
* _需求: 3.1_
*/
import { invoke } from "@tauri-apps/api/core";
/**
* 插件 UI 信息
*
* 描述带有 UI 配置的插件信息
*/
export interface PluginUIInfo {
/** 插件 ID */
pluginId: string;
/** 插件名称 */
name: string;
/** 插件描述 */
description: string;
/** 图标名称 (Lucide 图标) */
icon: string;
/** UI 展示位置列表 (如 "tools", "sidebar", "main") */
surfaces: string[];
}
/**
* 获取带有 UI 配置的已安装插件列表
*
* 从已安装插件中筛选带有 UI 配置的插件
* 返回 PluginUIInfo 列表,用于在工具页面或侧边栏显示
*
* @returns 带有 UI 配置的插件列表
*/
export async function getPluginsWithUI(): Promise<PluginUIInfo[]> {
return invoke<PluginUIInfo[]>("get_plugins_with_ui");
}
/**
* 获取指定 surface 的插件列表
*
* 筛选出在指定 surface 上显示的插件
*
* @param surface - UI 展示位置 (如 "tools", "sidebar", "main")
* @returns 在指定 surface 上显示的插件列表
*/
export async function getPluginsForSurface(
surface: string,
): Promise<PluginUIInfo[]> {
const plugins = await getPluginsWithUI();
return plugins.filter((plugin) => plugin.surfaces.includes(surface));
}
+106
View File
@@ -0,0 +1,106 @@
/**
* @file 组件注册表
* @description 管理插件 UI 可用的组件类型,实现组件白名单机制
* @module lib/plugin-ui/ComponentRegistry
*/
import type {
ComponentRenderer,
ComponentRegistryEntry,
ComponentTypeName,
} from "./types";
/**
* 组件注册表
* 管理所有可用的 UI 组件,插件只能使用已注册的组件
*/
export class ComponentRegistry {
private registry: Map<string, ComponentRegistryEntry> = new Map();
/**
* 注册组件
* @param typeName - 组件类型名称
* @param renderer - 组件渲染器
* @param schema - 可选的组件属性 schema
*/
register(
typeName: ComponentTypeName | string,
renderer: ComponentRenderer,
schema?: Record<string, unknown>,
): void {
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(typeName)) {
throw new Error(
`[ComponentRegistry] 无效的组件名称 '${typeName}',必须以字母开头且只包含字母数字`,
);
}
if (this.registry.has(typeName)) {
console.warn(`[ComponentRegistry] 组件 '${typeName}' 已存在,将被覆盖`);
}
this.registry.set(typeName, { renderer, schema });
}
/**
* 获取组件渲染器
* @param typeName - 组件类型名称
* @returns 组件渲染器,如果未注册则返回 undefined
*/
get(typeName: string): ComponentRenderer | undefined {
return this.registry.get(typeName)?.renderer;
}
/**
* 获取组件 schema
* @param typeName - 组件类型名称
* @returns 组件 schema
*/
getSchema(typeName: string): Record<string, unknown> | undefined {
return this.registry.get(typeName)?.schema;
}
/**
* 检查组件是否已注册
* @param typeName - 组件类型名称
*/
has(typeName: string): boolean {
return this.registry.has(typeName);
}
/**
* 获取所有已注册的组件类型名称
*/
getRegisteredTypes(): string[] {
return Array.from(this.registry.keys());
}
/**
* 批量注册组件
* @param components - 组件映射
*/
registerAll(components: Record<string, ComponentRenderer>): void {
for (const [typeName, renderer] of Object.entries(components)) {
this.register(typeName, renderer);
}
}
/**
* 注销组件
* @param typeName - 组件类型名称
*/
unregister(typeName: string): boolean {
return this.registry.delete(typeName);
}
/**
* 清空注册表
*/
clear(): void {
this.registry.clear();
}
}
/**
* 全局组件注册表实例
*/
export const componentRegistry = new ComponentRegistry();
+215
View File
@@ -0,0 +1,215 @@
/**
* @file 插件 UI 数据存储
* @description 管理 Surface 的数据模型,支持路径访问和更新
* @module lib/plugin-ui/DataStore
*/
import type { DataEntry, DataPath } from "./types";
/**
* 将 DataEntry 数组转换为普通对象
*/
export function dataEntriesToObject(
entries: DataEntry[],
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const entry of entries) {
if (entry.valueString !== undefined) {
result[entry.key] = entry.valueString;
} else if (entry.valueNumber !== undefined) {
result[entry.key] = entry.valueNumber;
} else if (entry.valueBoolean !== undefined) {
result[entry.key] = entry.valueBoolean;
} else if (entry.valueArray !== undefined) {
result[entry.key] = entry.valueArray.map(
(item) => dataEntriesToObject([item])[item.key],
);
} else if (entry.valueMap !== undefined) {
result[entry.key] = dataEntriesToObject(entry.valueMap);
}
}
return result;
}
/**
* 解析数据路径
* @param path - JSONPath 格式路径,如 '/user/name' 或 'name'
* @returns 路径段数组
*/
export function parsePath(path: DataPath): string[] {
if (!path || path === "/") return [];
// 移除开头的 /
const normalized = path.startsWith("/") ? path.slice(1) : path;
return normalized.split("/").filter(Boolean);
}
/**
* 从数据模型中获取指定路径的值
* @param data - 数据模型
* @param path - 数据路径
* @returns 路径对应的值
*/
export function getValueByPath(
data: Record<string, unknown>,
path: DataPath,
): unknown {
const segments = parsePath(path);
let current: unknown = data;
for (const segment of segments) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current === "object") {
if (Array.isArray(current)) {
const index = parseInt(segment, 10);
if (isNaN(index)) return undefined;
current = current[index];
} else {
current = (current as Record<string, unknown>)[segment];
}
} else {
return undefined;
}
}
return current;
}
/**
* 在数据模型中设置指定路径的值
* @param data - 数据模型
* @param path - 数据路径
* @param value - 要设置的值
* @returns 更新后的数据模型(新对象)
*/
export function setValueByPath(
data: Record<string, unknown>,
path: DataPath,
value: unknown,
): Record<string, unknown> {
const segments = parsePath(path);
if (segments.length === 0) {
// 替换整个数据模型
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return { ...(value as Record<string, unknown>) };
}
return data;
}
// 深拷贝并更新
const result = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
let current: Record<string, unknown> = result;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
const nextSegment = segments[i + 1];
const isNextArray = !isNaN(parseInt(nextSegment, 10));
if (current[segment] === undefined) {
current[segment] = isNextArray ? [] : {};
}
current = current[segment] as Record<string, unknown>;
}
const lastSegment = segments[segments.length - 1];
current[lastSegment] = value;
return result;
}
/**
* 合并数据更新到现有数据模型
* @param data - 现有数据模型
* @param path - 更新路径
* @param entries - 数据条目
* @returns 更新后的数据模型
*/
export function mergeDataUpdate(
data: Record<string, unknown>,
path: DataPath | undefined,
entries: DataEntry[],
): Record<string, unknown> {
const newData = dataEntriesToObject(entries);
if (!path || path === "/") {
// 合并到根
return { ...data, ...newData };
}
// 合并到指定路径
const existingValue = getValueByPath(data, path);
const mergedValue =
typeof existingValue === "object" && existingValue !== null
? { ...(existingValue as Record<string, unknown>), ...newData }
: newData;
return setValueByPath(data, path, mergedValue);
}
/**
* 数据存储类
* 管理单个 Surface 的数据模型
*/
export class DataStore {
private data: Record<string, unknown> = {};
private listeners: Set<(data: Record<string, unknown>) => void> = new Set();
/**
* 获取完整数据模型
*/
getData(): Record<string, unknown> {
return this.data;
}
/**
* 获取指定路径的值
*/
getValue(path: DataPath): unknown {
return getValueByPath(this.data, path);
}
/**
* 设置指定路径的值
*/
setValue(path: DataPath, value: unknown): void {
this.data = setValueByPath(this.data, path, value);
this.notifyListeners();
}
/**
* 应用数据更新
*/
applyUpdate(path: DataPath | undefined, entries: DataEntry[]): void {
this.data = mergeDataUpdate(this.data, path, entries);
this.notifyListeners();
}
/**
* 重置数据模型
*/
reset(initialData?: Record<string, unknown>): void {
this.data = initialData ?? {};
this.notifyListeners();
}
/**
* 订阅数据变化
*/
subscribe(listener: (data: Record<string, unknown>) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners(): void {
for (const listener of this.listeners) {
listener(this.data);
}
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* @file 插件 UI 容器组件
* @description 封装插件 UI 渲染的完整容器,包含加载状态和错误处理
* @module lib/plugin-ui/PluginUIContainer
*/
import React from "react";
import { Loader2, AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PluginUIRenderer } from "./PluginUIRenderer";
import { usePluginUI } from "./usePluginUI";
import type { PluginId } from "./types";
interface PluginUIContainerProps {
/** 插件 ID */
pluginId: PluginId;
/** 自定义类名 */
className?: string;
/** 空状态提示 */
emptyMessage?: string;
}
/**
* 插件 UI 容器
* 自动管理插件 UI 的加载、渲染和错误处理
*/
export const PluginUIContainer: React.FC<PluginUIContainerProps> = ({
pluginId,
className,
emptyMessage = "该插件没有提供 UI",
}) => {
const { surfaces, loading, error, handleAction, refresh } = usePluginUI({
pluginId,
});
// 加载状态
if (loading) {
return (
<div className={`flex items-center justify-center p-8 ${className}`}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">加载插件 UI...</span>
</div>
);
}
// 错误状态
if (error) {
return (
<div
className={`flex flex-col items-center justify-center p-8 ${className}`}
>
<AlertCircle className="h-8 w-8 text-red-500 mb-2" />
<p className="text-red-600 mb-4">{error}</p>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="h-4 w-4 mr-2" />
重试
</Button>
</div>
);
}
// 空状态
if (surfaces.length === 0) {
return (
<div
className={`flex items-center justify-center p-8 text-muted-foreground ${className}`}
>
{emptyMessage}
</div>
);
}
// 渲染所有 Surface
return (
<div className={`space-y-4 ${className}`}>
{surfaces.map((surface) => (
<PluginUIRenderer
key={surface.surfaceId}
surface={surface}
onAction={handleAction}
/>
))}
</div>
);
};
export default PluginUIContainer;
+257
View File
@@ -0,0 +1,257 @@
/**
* @file 插件 UI 渲染器
* @description 核心渲染组件,将声明式 UI 定义渲染为 React 组件
* @module lib/plugin-ui/PluginUIRenderer
*/
import React, { useCallback, useRef } from "react";
import { componentRegistry } from "./ComponentRegistry";
import { getValueByPath } from "./DataStore";
import type {
SurfaceState,
ComponentId,
ChildrenDef,
BoundValue,
UserAction,
ComponentRendererProps,
} from "./types";
interface PluginUIRendererProps {
/** Surface 状态 */
surface: SurfaceState;
/** 用户操作回调 */
onAction: (action: UserAction) => void;
/** 自定义类名 */
className?: string;
}
/**
* 插件 UI 渲染器
* 递归渲染 Surface 中的组件树
*/
export const PluginUIRenderer: React.FC<PluginUIRendererProps> = ({
surface,
onAction,
className,
}) => {
/**
* 解析绑定值
* @param bound - 绑定值定义
* @param itemData - 列表项数据(用于模板渲染)
*/
const resolveValue = useCallback(
<T,>(bound: BoundValue<T>, itemData?: unknown): T | undefined => {
if (!bound) return undefined;
// 字面值
if ("literalString" in bound && !("path" in bound)) {
return bound.literalString as T;
}
if ("literalNumber" in bound && !("path" in bound)) {
return bound.literalNumber as T;
}
if ("literalBoolean" in bound && !("path" in bound)) {
return bound.literalBoolean as T;
}
if ("literalArray" in bound && !("path" in bound)) {
return bound.literalArray as T;
}
// 路径绑定
if ("path" in bound) {
const path = bound.path as string;
// 相对路径(用于模板渲染)
if (itemData !== undefined && !path.startsWith("/")) {
if (typeof itemData === "object" && itemData !== null) {
return (itemData as Record<string, unknown>)[path] as T;
}
return itemData as T;
}
// 绝对路径
const value = getValueByPath(surface.dataModel, path);
if (value !== undefined) {
return value as T;
}
// 如果路径没有值,返回字面值作为默认值
if ("literalString" in bound) return bound.literalString as T;
if ("literalNumber" in bound) return bound.literalNumber as T;
if ("literalBoolean" in bound) return bound.literalBoolean as T;
}
return undefined;
},
[surface.dataModel],
);
/**
* 渲染子组件列表 - 使用 ref 解决循环依赖
*/
const renderChildrenRef =
useRef<(children: ChildrenDef, itemData?: unknown) => React.ReactNode[]>();
/**
* 渲染单个子组件
*/
const renderChild = useCallback(
(childId: ComponentId, itemData?: unknown): React.ReactNode => {
const childDef = surface.components.get(childId);
if (!childDef) {
console.warn(`[PluginUI] 组件 '${childId}' 未找到`);
return null;
}
return (
<ComponentRenderer
key={childId}
componentDef={childDef}
surface={surface}
onAction={onAction}
resolveValue={(bound) => resolveValue(bound, itemData)}
renderChild={(id) => renderChild(id, itemData)}
renderChildren={(children) =>
renderChildrenRef.current?.(children, itemData) ?? []
}
itemData={itemData}
/>
);
},
[surface, onAction, resolveValue],
);
/**
* 渲染子组件列表
*/
const renderChildren = useCallback(
(children: ChildrenDef, itemData?: unknown): React.ReactNode[] => {
// 显式列表
if (children.explicitList) {
return children.explicitList.map((childId) =>
renderChild(childId, itemData),
);
}
// 模板渲染
if (children.template) {
const { componentId, dataBinding } = children.template;
const listData = getValueByPath(surface.dataModel, dataBinding);
if (!Array.isArray(listData)) {
console.warn(`[PluginUI] 模板数据绑定 '${dataBinding}' 不是数组`);
return [];
}
return listData.map((item, index) => (
<React.Fragment key={index}>
{renderChild(componentId, item)}
</React.Fragment>
));
}
return [];
},
[surface.dataModel, renderChild],
);
// 更新 ref
renderChildrenRef.current = renderChildren;
// 如果 Surface 未就绪,显示加载状态
if (!surface.isReady || !surface.rootId) {
return (
<div className={className}>
<div className="flex items-center justify-center p-4 text-muted-foreground">
加载中...
</div>
</div>
);
}
// 渲染根组件
return (
<div className={className} style={getSurfaceStyles(surface)}>
{renderChild(surface.rootId)}
</div>
);
};
/**
* 获取 Surface 样式
*/
function getSurfaceStyles(surface: SurfaceState): React.CSSProperties {
const styles: Record<string, string | number> = {};
if (surface.styles.primaryColor) {
styles["--primary-color"] = surface.styles.primaryColor;
}
if (surface.styles.font) {
styles["fontFamily"] = surface.styles.font;
}
if (surface.styles.borderRadius !== undefined) {
styles["--border-radius"] = `${surface.styles.borderRadius}px`;
}
return styles as React.CSSProperties;
}
/**
* 单个组件渲染器
*/
interface ComponentRendererInternalProps extends Omit<
ComponentRendererProps,
"resolveValue" | "renderChild" | "renderChildren"
> {
resolveValue: <T>(bound: BoundValue<T>) => T | undefined;
renderChild: (childId: ComponentId) => React.ReactNode;
renderChildren: (children: ChildrenDef) => React.ReactNode[];
itemData?: unknown;
}
const ComponentRenderer: React.FC<ComponentRendererInternalProps> = ({
componentDef,
surface,
onAction,
resolveValue,
renderChild,
renderChildren,
}) => {
// 获取组件类型
const componentType = Object.keys(componentDef.component)[0];
// 从注册表获取渲染器
const Renderer = componentRegistry.get(componentType);
if (!Renderer) {
console.warn(`[PluginUI] 未注册的组件类型: ${componentType}`);
return (
<div className="p-2 text-sm text-red-500 bg-red-50 rounded">
未知组件: {componentType}
</div>
);
}
// 应用 weight 样式
const style: React.CSSProperties = {};
if (componentDef.weight !== undefined) {
style.flexGrow = componentDef.weight;
}
return (
<div style={style}>
<Renderer
componentDef={componentDef}
surface={surface}
onAction={onAction}
resolveValue={resolveValue}
renderChild={renderChild}
renderChildren={renderChildren}
/>
</div>
);
};
export default PluginUIRenderer;
+53
View File
@@ -0,0 +1,53 @@
# plugin-ui
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
## 架构说明
基于 A2UI 设计理念的声明式插件 UI 系统。
核心思想:
- **安全如数据,表达如代码**:插件只能声明 UI 结构,不能执行任意代码
- **声明式 JSON 格式**:插件通过 JSON 描述 UI 意图,宿主应用负责渲染
- **组件目录(Catalog)机制**:预定义可用组件集,插件只能使用目录中的组件
- **数据绑定分离**:UI 结构与数据模型分离,支持增量更新
## 文件索引
- `types.ts` - 类型定义(组件、消息、状态等)
- `ComponentRegistry.ts` - 组件注册表,管理可用组件白名单
- `DataStore.ts` - 数据存储,支持路径访问和更新
- `SurfaceManager.ts` - Surface 管理器,处理 UI 消息
- `PluginUIRenderer.tsx` - 核心渲染器组件
- `PluginUIContainer.tsx` - 封装容器,含加载/错误状态
- `usePluginUI.ts` - React Hook,管理插件 UI 状态
- `index.ts` - 导出入口
- `components/` - 标准组件实现
- `layout.tsx` - 布局组件(Row, Column, Card, Tabs)
- `display.tsx` - 展示组件(Text, Icon, Badge, Progress 等)
- `input.tsx` - 输入组件(Button, TextField, Switch, Select)
- `data.tsx` - 数据组件(List, KeyValue, Alert)
- `index.ts` - 组件导出
## 使用示例
```tsx
import { PluginUIContainer } from '@/lib/plugin-ui';
function PluginDetailPage({ pluginId }) {
return (
<div>
<h2>插件详情</h2>
<PluginUIContainer pluginId={pluginId} />
</div>
);
}
```
## 设计文档
详细设计见 `docs/plugin-ui-design.md`
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+271
View File
@@ -0,0 +1,271 @@
/**
* @file SurfaceManager 属性测试
* @description 测试插件 UI Surface 注册一致性
* @module lib/plugin-ui/SurfaceManager.test
*
* **Feature: machine-id-plugin-migration, 属性 1: 插件 UI Surface 注册一致性**
* **Validates: Requirements 1.2, 3.1**
*/
import { describe, expect } from "vitest";
import { test } from "@fast-check/vitest";
import * as fc from "fast-check";
import { SurfaceManager } from "./SurfaceManager";
import type { ServerMessage } from "./types";
describe("SurfaceManager", () => {
/**
* 属性 1: 插件 UI Surface 注册一致性
*
* *对于任意*已安装的插件,如果其 manifest 包含 UI 配置,
* 则该插件必须出现在其声明的所有 surfaces 中。
*
* **Validates: Requirements 1.2, 3.1**
*/
describe("属性 1: 插件 UI Surface 注册一致性", () => {
// 生成有效的标识符(字母数字)
const identifierArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_-]{0,20}$/);
test.prop([identifierArb, identifierArb, identifierArb])(
"注册的 Surface 应该可以通过 pluginId 查询到",
(pluginId, surfaceId, rootId) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 发送 surfaceUpdate 创建 surface
const updateMessage: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId, updateMessage);
// 发送 beginRendering 标记为就绪
const beginMessage: ServerMessage = {
beginRendering: {
surfaceId,
root: rootId,
},
};
manager.processMessage(pluginId, beginMessage);
// 验证 surface 可以通过 pluginId 查询到
const surfaces = manager.getSurfacesByPlugin(pluginId);
const found = surfaces.some((s) => s.surfaceId === surfaceId);
expect(found).toBe(true);
},
);
test.prop([identifierArb, identifierArb, identifierArb])(
"注册的 Surface 应该可以通过 surfaceId 直接获取",
(pluginId, surfaceId, rootId) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 发送 surfaceUpdate 创建 surface
const updateMessage: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId, updateMessage);
// 验证 surface 可以直接获取
const surface = manager.getSurface(surfaceId);
expect(surface).toBeDefined();
expect(surface?.pluginId).toBe(pluginId);
expect(surface?.surfaceId).toBe(surfaceId);
},
);
test.prop([
identifierArb,
identifierArb,
fc.array(
fc.record({
id: identifierArb,
text: fc.string({ minLength: 0, maxLength: 100 }),
}),
{ minLength: 1, maxLength: 5 },
),
])("Surface 更新应该正确累积组件", (pluginId, surfaceId, components) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 逐个添加组件
for (const comp of components) {
const message: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: comp.id,
component: { Text: { text: { literalString: comp.text } } },
},
],
},
};
manager.processMessage(pluginId, message);
}
// 验证所有组件都被注册
const surface = manager.getSurface(surfaceId);
expect(surface).toBeDefined();
for (const comp of components) {
expect(surface?.components.has(comp.id)).toBe(true);
}
});
test.prop([identifierArb, identifierArb, identifierArb])(
"删除 Surface 后应该无法查询到",
(pluginId, surfaceId, rootId) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 先注册 surface
const updateMessage: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId, updateMessage);
// 验证 surface 存在
expect(manager.getSurface(surfaceId)).toBeDefined();
// 删除 surface
const deleteMessage: ServerMessage = {
deleteSurface: {
surfaceId,
},
};
manager.processMessage(pluginId, deleteMessage);
// 验证 surface 已被删除
expect(manager.getSurface(surfaceId)).toBeUndefined();
},
);
test.prop([identifierArb, fc.integer({ min: 1, max: 5 })])(
"清理插件时应该删除该插件的所有 Surface",
(pluginId, surfaceCount) => {
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
const surfaceIds = Array.from(
{ length: surfaceCount },
(_, i) => `surface-${i}`,
);
// 为同一个插件注册多个 surfaces
for (const surfaceId of surfaceIds) {
const message: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: "root",
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId, message);
}
// 验证所有 surfaces 都存在
for (const surfaceId of surfaceIds) {
expect(manager.getSurface(surfaceId)).toBeDefined();
}
// 清理插件
manager.clearPlugin(pluginId);
// 验证所有 surfaces 都被删除
for (const surfaceId of surfaceIds) {
expect(manager.getSurface(surfaceId)).toBeUndefined();
}
},
);
test.prop([
identifierArb,
identifierArb,
fc.array(identifierArb, { minLength: 1, maxLength: 3 }),
])("多个插件可以注册不同的 Surface", (pluginId1, pluginId2, surfaceIds) => {
// 假设两个不同的插件
fc.pre(pluginId1 !== pluginId2);
// 每次迭代创建新的 manager
const manager = new SurfaceManager();
// 为第一个插件注册 surfaces
for (let i = 0; i < surfaceIds.length; i++) {
const surfaceId = `${pluginId1}-${surfaceIds[i]}`;
const message: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: "root",
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId1, message);
}
// 为第二个插件注册 surfaces
for (let i = 0; i < surfaceIds.length; i++) {
const surfaceId = `${pluginId2}-${surfaceIds[i]}`;
const message: ServerMessage = {
surfaceUpdate: {
surfaceId,
components: [
{
id: "root",
component: { Text: { text: { literalString: "test" } } },
},
],
},
};
manager.processMessage(pluginId2, message);
}
// 验证每个插件只能查询到自己的 surfaces
const surfaces1 = manager.getSurfacesByPlugin(pluginId1);
const surfaces2 = manager.getSurfacesByPlugin(pluginId2);
expect(surfaces1.length).toBe(surfaceIds.length);
expect(surfaces2.length).toBe(surfaceIds.length);
// 验证 surfaces 属于正确的插件
for (const surface of surfaces1) {
expect(surface.pluginId).toBe(pluginId1);
}
for (const surface of surfaces2) {
expect(surface.pluginId).toBe(pluginId2);
}
});
});
});
+233
View File
@@ -0,0 +1,233 @@
/**
* @file Surface 管理器
* @description 管理插件的 UI Surface 状态
* @module lib/plugin-ui/SurfaceManager
*/
import type {
SurfaceId,
PluginId,
ComponentId,
ComponentDef,
SurfaceState,
SurfaceStyles,
ServerMessage,
DataEntry,
} from "./types";
import { DataStore } from "./DataStore";
/**
* Surface 管理器
* 管理所有插件的 UI Surface
*/
export class SurfaceManager {
private surfaces: Map<SurfaceId, SurfaceState> = new Map();
private dataStores: Map<SurfaceId, DataStore> = new Map();
private listeners: Set<(surfaces: Map<SurfaceId, SurfaceState>) => void> =
new Set();
/**
* 处理服务端消息
*/
processMessage(pluginId: PluginId, message: ServerMessage): void {
if ("surfaceUpdate" in message) {
this.handleSurfaceUpdate(
pluginId,
message.surfaceUpdate.surfaceId,
message.surfaceUpdate.components,
);
} else if ("dataModelUpdate" in message) {
this.handleDataModelUpdate(
message.dataModelUpdate.surfaceId,
message.dataModelUpdate.path,
message.dataModelUpdate.contents,
);
} else if ("beginRendering" in message) {
this.handleBeginRendering(
pluginId,
message.beginRendering.surfaceId,
message.beginRendering.root,
message.beginRendering.styles,
);
} else if ("deleteSurface" in message) {
this.handleDeleteSurface(message.deleteSurface.surfaceId);
}
}
/**
* 处理 Surface 更新
*/
private handleSurfaceUpdate(
pluginId: PluginId,
surfaceId: SurfaceId,
components: ComponentDef[],
): void {
let surface = this.surfaces.get(surfaceId);
if (!surface) {
// 创建新 Surface
surface = {
surfaceId,
pluginId,
rootId: null,
components: new Map(),
dataModel: {},
styles: {},
isReady: false,
};
this.surfaces.set(surfaceId, surface);
this.dataStores.set(surfaceId, new DataStore());
}
// 更新组件
for (const comp of components) {
surface.components.set(comp.id, comp);
}
this.notifyListeners();
}
/**
* 处理数据模型更新
*/
private handleDataModelUpdate(
surfaceId: SurfaceId,
path: string | undefined,
contents: DataEntry[],
): void {
const surface = this.surfaces.get(surfaceId);
const dataStore = this.dataStores.get(surfaceId);
if (!surface || !dataStore) {
console.warn(`[SurfaceManager] Surface '${surfaceId}' 不存在`);
return;
}
// 更新数据存储
dataStore.applyUpdate(path, contents);
// 同步到 surface 状态
surface.dataModel = dataStore.getData();
this.notifyListeners();
}
/**
* 处理开始渲染
*/
private handleBeginRendering(
pluginId: PluginId,
surfaceId: SurfaceId,
rootId: ComponentId,
styles?: SurfaceStyles,
): void {
let surface = this.surfaces.get(surfaceId);
if (!surface) {
// 如果 Surface 不存在,创建一个
surface = {
surfaceId,
pluginId,
rootId: null,
components: new Map(),
dataModel: {},
styles: {},
isReady: false,
};
this.surfaces.set(surfaceId, surface);
this.dataStores.set(surfaceId, new DataStore());
}
surface.rootId = rootId;
surface.isReady = true;
if (styles) {
surface.styles = { ...surface.styles, ...styles };
}
this.notifyListeners();
}
/**
* 处理删除 Surface
*/
private handleDeleteSurface(surfaceId: SurfaceId): void {
this.surfaces.delete(surfaceId);
this.dataStores.delete(surfaceId);
this.notifyListeners();
}
/**
* 获取 Surface 状态
*/
getSurface(surfaceId: SurfaceId): SurfaceState | undefined {
return this.surfaces.get(surfaceId);
}
/**
* 获取插件的所有 Surface
*/
getSurfacesByPlugin(pluginId: PluginId): SurfaceState[] {
return Array.from(this.surfaces.values()).filter(
(s) => s.pluginId === pluginId,
);
}
/**
* 获取所有 Surface
*/
getAllSurfaces(): Map<SurfaceId, SurfaceState> {
return new Map(this.surfaces);
}
/**
* 获取 Surface 的数据存储
*/
getDataStore(surfaceId: SurfaceId): DataStore | undefined {
return this.dataStores.get(surfaceId);
}
/**
* 订阅 Surface 变化
*/
subscribe(
listener: (surfaces: Map<SurfaceId, SurfaceState>) => void,
): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* 清理插件的所有 Surface
*/
clearPlugin(pluginId: PluginId): void {
for (const [surfaceId, surface] of this.surfaces) {
if (surface.pluginId === pluginId) {
this.surfaces.delete(surfaceId);
this.dataStores.delete(surfaceId);
}
}
this.notifyListeners();
}
/**
* 清理所有 Surface
*/
clear(): void {
this.surfaces.clear();
this.dataStores.clear();
this.notifyListeners();
}
private notifyListeners(): void {
const snapshot = new Map(this.surfaces);
for (const listener of this.listeners) {
listener(snapshot);
}
}
}
/**
* 全局 Surface 管理器实例
*/
export const surfaceManager = new SurfaceManager();
+48
View File
@@ -0,0 +1,48 @@
# components
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
## 架构说明
插件 UI 标准组件实现。
所有组件基于 shadcn/ui 和 TailwindCSS 构建,提供一致的视觉风格。
## 文件索引
- `layout.tsx` - 布局组件
- `Row` - 水平布局
- `Column` - 垂直布局
- `Card` - 卡片容器
- `Tabs` - 标签页
- `display.tsx` - 展示组件
- `Text` - 文本
- `Icon` - 图标
- `Badge` - 徽章
- `Progress` - 进度条
- `Spinner` - 加载指示器
- `Empty` - 空状态
- `Divider` - 分隔线
- `input.tsx` - 输入组件
- `Button` - 按钮
- `TextField` - 文本输入
- `Switch` - 开关
- `Select` - 下拉选择
- `data.tsx` - 数据展示组件
- `List` - 列表(支持模板渲染)
- `KeyValue` - 键值对
- `Alert` - 提示框
- `index.ts` - 导出入口
## 扩展组件
可通过 `componentRegistry.register()` 注册自定义组件:
```tsx
import { componentRegistry } from '@/lib/plugin-ui';
componentRegistry.register('CustomCard', CustomCardRenderer);
```
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+134
View File
@@ -0,0 +1,134 @@
/**
* @file 数据展示组件
* @description List, KeyValue, Alert 等数据展示组件实现
* @module lib/plugin-ui/components/data
*/
import React from "react";
import { cn } from "@/lib/utils";
import {
Alert as UIAlert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { Info, CheckCircle, AlertTriangle, AlertCircle } from "lucide-react";
import type {
ComponentRendererProps,
ListComponent,
KeyValueComponent,
AlertComponent,
AlertType,
Alignment,
} from "../types";
/**
* 对齐方式映射
*/
const alignmentMap: Record<Alignment, string> = {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
};
/**
* List 组件 - 列表容器,支持模板渲染
*/
export const ListRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
renderChildren,
}) => {
const props = (componentDef.component as ListComponent).List;
const {
children,
direction = "vertical",
alignment = "stretch",
gap = 8,
} = props;
const isHorizontal = direction === "horizontal";
return (
<div
className={cn(
"flex",
isHorizontal ? "flex-row flex-wrap" : "flex-col",
alignmentMap[alignment],
)}
style={{ gap: `${gap}px` }}
>
{renderChildren(children)}
</div>
);
};
/**
* KeyValue 组件 - 键值对展示
*/
export const KeyValueRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as KeyValueComponent).KeyValue;
const { items } = props;
return (
<div className="space-y-2">
{items.map((item, index) => {
const keyText = resolveValue(item.key) || "";
const valueText = resolveValue(item.value) || "";
return (
<div key={index} className="flex justify-between items-center py-1">
<span className="text-sm text-muted-foreground">{keyText}</span>
<span className="text-sm font-medium">{valueText}</span>
</div>
);
})}
</div>
);
};
/**
* Alert 图标映射
*/
const alertIconMap: Record<AlertType, React.FC<{ className?: string }>> = {
info: Info,
success: CheckCircle,
warning: AlertTriangle,
error: AlertCircle,
};
/**
* Alert 样式映射
*/
const alertStyleMap: Record<AlertType, string> = {
info: "border-blue-200 bg-blue-50 text-blue-800 [&>svg]:text-blue-500",
success: "border-green-200 bg-green-50 text-green-800 [&>svg]:text-green-500",
warning:
"border-yellow-200 bg-yellow-50 text-yellow-800 [&>svg]:text-yellow-500",
error: "border-red-200 bg-red-50 text-red-800 [&>svg]:text-red-500",
};
/**
* Alert 组件
*/
export const AlertRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as AlertComponent).Alert;
const { message, type, title } = props;
const messageText = resolveValue(message) || "";
const titleText = title ? resolveValue(title) : undefined;
const IconComponent = alertIconMap[type];
return (
<UIAlert className={alertStyleMap[type]}>
<IconComponent className="h-4 w-4" />
{titleText && <AlertTitle>{titleText}</AlertTitle>}
<AlertDescription>{messageText}</AlertDescription>
</UIAlert>
);
};
+259
View File
@@ -0,0 +1,259 @@
/**
* @file 展示组件
* @description Text, Icon, Badge, Progress 等展示组件实现
* @module lib/plugin-ui/components/display
*/
import React from "react";
import { cn } from "@/lib/utils";
import { Badge as UIBadge } from "@/components/ui/badge";
import { Progress as UIProgress } from "@/components/ui/progress";
import {
Plus,
Check,
X,
Trash2,
Edit,
RefreshCw,
Search,
Settings,
Info,
AlertTriangle,
AlertCircle,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Power,
PowerOff,
Folder,
File,
Copy,
Play,
Pause,
Square,
Download,
Upload,
Loader2,
type LucideIcon,
} from "lucide-react";
import type {
ComponentRendererProps,
TextComponent,
IconComponent,
BadgeComponent,
ProgressComponent,
SpinnerComponent,
EmptyComponent,
DividerComponent,
TextVariant,
BadgeVariant,
IconName,
} from "../types";
/**
* 文本变体样式映射
*/
const textVariantStyles: Record<TextVariant, string> = {
h1: "text-4xl font-bold",
h2: "text-3xl font-semibold",
h3: "text-2xl font-semibold",
h4: "text-xl font-medium",
h5: "text-lg font-medium",
body: "text-base",
caption: "text-sm text-muted-foreground",
};
/**
* Text 组件
*/
export const TextRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as TextComponent).Text;
const { text, variant = "body" } = props;
const textContent = resolveValue(text) || "";
return <span className={textVariantStyles[variant]}>{textContent}</span>;
};
/**
* 图标映射
*/
const iconMap: Record<IconName, LucideIcon> = {
add: Plus,
check: Check,
close: X,
delete: Trash2,
edit: Edit,
refresh: RefreshCw,
search: Search,
settings: Settings,
info: Info,
warning: AlertTriangle,
error: AlertCircle,
chevronDown: ChevronDown,
chevronUp: ChevronUp,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
power: Power,
powerOff: PowerOff,
folder: Folder,
file: File,
copy: Copy,
play: Play,
pause: Pause,
stop: Square,
download: Download,
upload: Upload,
};
/**
* Icon 组件
*/
export const IconRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as IconComponent).Icon;
const { name, size = 16, color } = props;
const iconName = resolveValue(name) as IconName;
const IconComponent = iconMap[iconName];
if (!IconComponent) {
console.warn(`[PluginUI] 未知图标: ${iconName}`);
return null;
}
return (
<IconComponent className={cn("inline-block")} size={size} color={color} />
);
};
/**
* Badge 变体样式映射
*/
const badgeVariantMap: Record<
BadgeVariant,
"default" | "secondary" | "destructive" | "outline"
> = {
default: "default",
success: "default",
warning: "secondary",
error: "destructive",
info: "outline",
};
const badgeColorMap: Record<BadgeVariant, string> = {
default: "",
success: "bg-green-100 text-green-800 hover:bg-green-100",
warning: "bg-yellow-100 text-yellow-800 hover:bg-yellow-100",
error: "",
info: "bg-blue-100 text-blue-800 hover:bg-blue-100",
};
/**
* Badge 组件
*/
export const BadgeRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as BadgeComponent).Badge;
const { text, variant } = props;
const textContent = resolveValue(text) || "";
const variantValue = (
variant ? resolveValue(variant) : "default"
) as BadgeVariant;
return (
<UIBadge
variant={badgeVariantMap[variantValue]}
className={badgeColorMap[variantValue]}
>
{textContent}
</UIBadge>
);
};
/**
* Progress 组件
*/
export const ProgressRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as ProgressComponent).Progress;
const { value, max = 100 } = props;
const numValue = resolveValue(value) || 0;
const percentage = (numValue / max) * 100;
return <UIProgress value={percentage} className="w-full" />;
};
/**
* Spinner 组件
*/
export const SpinnerRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
}) => {
const props = (componentDef.component as SpinnerComponent).Spinner;
const { size = 24 } = props;
return (
<Loader2
className="animate-spin text-muted-foreground"
style={{ width: size, height: size }}
/>
);
};
/**
* Empty 组件
*/
export const EmptyRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
}) => {
const props = (componentDef.component as EmptyComponent).Empty;
const { icon, title, description } = props;
const IconComponent = icon ? iconMap[icon] : null;
const titleText = title ? resolveValue(title) : undefined;
const descText = description ? resolveValue(description) : undefined;
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
{IconComponent && (
<IconComponent className="h-12 w-12 text-muted-foreground/50 mb-4" />
)}
{titleText && (
<p className="text-lg font-medium text-muted-foreground">{titleText}</p>
)}
{descText && (
<p className="text-sm text-muted-foreground/70 mt-1">{descText}</p>
)}
</div>
);
};
/**
* Divider 组件
*/
export const DividerRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
}) => {
const props = (componentDef.component as DividerComponent).Divider;
const { axis = "horizontal" } = props;
if (axis === "vertical") {
return <div className="w-px h-full bg-border" />;
}
return <div className="h-px w-full bg-border" />;
};
+30
View File
@@ -0,0 +1,30 @@
/**
* @file 组件导出入口
* @description 导出所有标准组件并注册到组件注册表
* @module lib/plugin-ui/components
*/
import { componentRegistry as globalRegistry } from "../ComponentRegistry";
import { componentRegistry as standardComponents } from "./registry";
/**
* 注册所有标准组件到全局注册表
*/
export function registerStandardComponents(): void {
globalRegistry.registerAll(standardComponents);
}
// 导出各类组件
export {
layoutComponents,
displayComponents,
inputComponents,
dataComponents,
componentRegistry as standardComponents,
} from "./registry";
// 导出各个渲染器
export * from "./layout";
export * from "./display";
export * from "./input";
export * from "./data";
+264
View File
@@ -0,0 +1,264 @@
/**
* @file 输入组件
* @description Button, TextField, Switch, Select 等输入组件实现
* @module lib/plugin-ui/components/input
*/
import React, { useCallback } from "react";
import { Button as UIButton } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch as UISwitch } from "@/components/ui/switch";
import {
Select as UISelect,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
ComponentRendererProps,
ButtonComponent,
TextFieldComponent,
SwitchComponent,
SelectComponent,
UserAction,
ButtonVariant,
} from "../types";
/**
* 按钮变体映射
*/
const buttonVariantMap: Record<
ButtonVariant,
"default" | "destructive" | "outline" | "secondary" | "ghost"
> = {
default: "default",
primary: "default",
secondary: "secondary",
destructive: "destructive",
outline: "outline",
ghost: "ghost",
};
/**
* Button 组件
*/
export const ButtonRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
surface,
onAction,
resolveValue,
renderChild,
}) => {
const props = (componentDef.component as ButtonComponent).Button;
const { child, action, variant = "default", disabled } = props;
const isDisabled = disabled ? resolveValue(disabled) : false;
const handleClick = useCallback(() => {
// 解析 action context
const context: Record<string, unknown> = {};
if (action.context) {
for (const item of action.context) {
context[item.key] = resolveValue(item.value);
}
}
const userAction: UserAction = {
name: action.name,
surfaceId: surface.surfaceId,
sourceComponentId: componentDef.id,
context,
timestamp: new Date().toISOString(),
};
onAction(userAction);
}, [action, surface.surfaceId, componentDef.id, onAction, resolveValue]);
return (
<UIButton
variant={buttonVariantMap[variant]}
disabled={isDisabled}
onClick={handleClick}
>
{renderChild(child)}
</UIButton>
);
};
/**
* TextField 组件
*/
export const TextFieldRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
surface,
onAction,
resolveValue,
}) => {
const props = (componentDef.component as TextFieldComponent).TextField;
const { label, value, placeholder, type = "text", disabled } = props;
const labelText = resolveValue(label) || "";
const valueText = value ? resolveValue(value) || "" : "";
const placeholderText = placeholder ? resolveValue(placeholder) : undefined;
const isDisabled = disabled ? resolveValue(disabled) : false;
// 获取绑定路径用于更新
const valuePath = value && "path" in value ? value.path : null;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!valuePath) return;
const userAction: UserAction = {
name: "__data_update__",
surfaceId: surface.surfaceId,
sourceComponentId: componentDef.id,
context: {
path: valuePath,
value: e.target.value,
},
timestamp: new Date().toISOString(),
};
onAction(userAction);
},
[valuePath, surface.surfaceId, componentDef.id, onAction],
);
const inputId = `input-${componentDef.id}`;
return (
<div className="space-y-2">
<Label htmlFor={inputId}>{labelText}</Label>
<Input
id={inputId}
type={type}
value={valueText}
placeholder={placeholderText}
disabled={isDisabled}
onChange={handleChange}
/>
</div>
);
};
/**
* Switch 组件
*/
export const SwitchRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
surface,
onAction,
resolveValue,
}) => {
const props = (componentDef.component as SwitchComponent).Switch;
const { label, checked, disabled } = props;
const labelText = resolveValue(label) || "";
const isChecked = resolveValue(checked) || false;
const isDisabled = disabled ? resolveValue(disabled) : false;
// 获取绑定路径用于更新
const checkedPath = "path" in checked ? checked.path : null;
const handleChange = useCallback(
(newChecked: boolean) => {
if (!checkedPath) return;
const userAction: UserAction = {
name: "__data_update__",
surfaceId: surface.surfaceId,
sourceComponentId: componentDef.id,
context: {
path: checkedPath,
value: newChecked,
},
timestamp: new Date().toISOString(),
};
onAction(userAction);
},
[checkedPath, surface.surfaceId, componentDef.id, onAction],
);
const switchId = `switch-${componentDef.id}`;
return (
<div className="flex items-center space-x-2">
<UISwitch
id={switchId}
checked={isChecked}
disabled={isDisabled}
onCheckedChange={handleChange}
/>
<Label htmlFor={switchId}>{labelText}</Label>
</div>
);
};
/**
* Select 组件
*/
export const SelectRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
surface,
onAction,
resolveValue,
}) => {
const props = (componentDef.component as SelectComponent).Select;
const { label, value, options, disabled } = props;
const labelText = label ? resolveValue(label) : undefined;
const selectedValue = resolveValue(value) || "";
const isDisabled = disabled ? resolveValue(disabled) : false;
// 获取绑定路径用于更新
const valuePath = "path" in value ? value.path : null;
const handleChange = useCallback(
(newValue: string) => {
if (!valuePath) return;
const userAction: UserAction = {
name: "__data_update__",
surfaceId: surface.surfaceId,
sourceComponentId: componentDef.id,
context: {
path: valuePath,
value: newValue,
},
timestamp: new Date().toISOString(),
};
onAction(userAction);
},
[valuePath, surface.surfaceId, componentDef.id, onAction],
);
return (
<div className="space-y-2">
{labelText && <Label>{labelText}</Label>}
<UISelect
value={selectedValue}
disabled={isDisabled}
onValueChange={handleChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option, index) => {
const optionLabel = resolveValue(option.label) || option.value;
return (
<SelectItem key={index} value={option.value}>
{optionLabel}
</SelectItem>
);
})}
</SelectContent>
</UISelect>
</div>
);
};
+175
View File
@@ -0,0 +1,175 @@
/**
* @file 布局组件
* @description Row, Column, Card, Tabs 等布局组件实现
* @module lib/plugin-ui/components/layout
*/
import React from "react";
import { cn } from "@/lib/utils";
import {
Card as UICard,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import {
Tabs as UITabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import type {
ComponentRendererProps,
RowComponent,
ColumnComponent,
CardComponent,
TabsComponent,
Distribution,
Alignment,
} from "../types";
/**
* 分布方式映射到 CSS justify-content
*/
const distributionMap: Record<Distribution, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
spaceBetween: "justify-between",
spaceAround: "justify-around",
spaceEvenly: "justify-evenly",
};
/**
* 对齐方式映射到 CSS align-items
*/
const alignmentMap: Record<Alignment, string> = {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
};
/**
* Row 组件 - 水平布局
*/
export const RowRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
renderChildren,
}) => {
const props = (componentDef.component as RowComponent).Row;
const {
children,
distribution = "start",
alignment = "center",
gap = 8,
} = props;
return (
<div
className={cn(
"flex flex-row",
distributionMap[distribution],
alignmentMap[alignment],
)}
style={{ gap: `${gap}px` }}
>
{renderChildren(children)}
</div>
);
};
/**
* Column 组件 - 垂直布局
*/
export const ColumnRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
renderChildren,
}) => {
const props = (componentDef.component as ColumnComponent).Column;
const {
children,
distribution = "start",
alignment = "stretch",
gap = 8,
} = props;
return (
<div
className={cn(
"flex flex-col",
distributionMap[distribution],
alignmentMap[alignment],
)}
style={{ gap: `${gap}px` }}
>
{renderChildren(children)}
</div>
);
};
/**
* Card 组件 - 卡片容器
*/
export const CardRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
renderChild,
}) => {
const props = (componentDef.component as CardComponent).Card;
const { child, title, description } = props;
const titleText = title ? resolveValue(title) : undefined;
const descText = description ? resolveValue(description) : undefined;
const hasHeader = titleText || descText;
return (
<UICard>
{hasHeader && (
<CardHeader>
{titleText && <CardTitle>{titleText}</CardTitle>}
{descText && <CardDescription>{descText}</CardDescription>}
</CardHeader>
)}
<CardContent className={hasHeader ? "" : "pt-6"}>
{renderChild(child)}
</CardContent>
</UICard>
);
};
/**
* Tabs 组件 - 标签页
*/
export const TabsRenderer: React.FC<ComponentRendererProps> = ({
componentDef,
resolveValue,
renderChild,
}) => {
const props = (componentDef.component as TabsComponent).Tabs;
const { items, defaultTab } = props;
const defaultValue = defaultTab || items[0]?.id;
return (
<UITabs defaultValue={defaultValue} className="w-full">
<TabsList>
{items.map((item) => {
const tabTitle = resolveValue(item.title) || item.id;
return (
<TabsTrigger key={item.id} value={item.id}>
{tabTitle}
</TabsTrigger>
);
})}
</TabsList>
{items.map((item) => (
<TabsContent key={item.id} value={item.id}>
{renderChild(item.child)}
</TabsContent>
))}
</UITabs>
);
};
+80
View File
@@ -0,0 +1,80 @@
/**
* @file 组件注册表
* @description 汇总所有插件 UI 组件的注册表
* @module lib/plugin-ui/components/registry
*/
import { ListRenderer, KeyValueRenderer, AlertRenderer } from "./data";
import {
TextRenderer,
IconRenderer,
BadgeRenderer,
ProgressRenderer,
SpinnerRenderer,
EmptyRenderer,
DividerRenderer,
} from "./display";
import {
ButtonRenderer,
TextFieldRenderer,
SwitchRenderer,
SelectRenderer,
} from "./input";
import {
RowRenderer,
ColumnRenderer,
CardRenderer,
TabsRenderer,
} from "./layout";
/**
* 数据展示组件映射
*/
export const dataComponents = {
List: ListRenderer,
KeyValue: KeyValueRenderer,
Alert: AlertRenderer,
};
/**
* 展示组件映射
*/
export const displayComponents = {
Text: TextRenderer,
Icon: IconRenderer,
Badge: BadgeRenderer,
Progress: ProgressRenderer,
Spinner: SpinnerRenderer,
Empty: EmptyRenderer,
Divider: DividerRenderer,
};
/**
* 输入组件映射
*/
export const inputComponents = {
Button: ButtonRenderer,
TextField: TextFieldRenderer,
Switch: SwitchRenderer,
Select: SelectRenderer,
};
/**
* 布局组件映射
*/
export const layoutComponents = {
Row: RowRenderer,
Column: ColumnRenderer,
Card: CardRenderer,
Tabs: TabsRenderer,
};
/**
* 所有组件注册表
*/
export const componentRegistry = {
...dataComponents,
...displayComponents,
...inputComponents,
...layoutComponents,
};
+118
View File
@@ -0,0 +1,118 @@
/**
* @file 插件 UI 系统入口
* @description 导出插件 UI 系统的所有公共 API
* @module lib/plugin-ui
*/
// 类型导出
export type {
// 基础类型
ComponentId,
SurfaceId,
PluginId,
DataPath,
// 数据绑定
BoundValue,
BoundString,
BoundNumber,
BoundBoolean,
ChildrenDef,
Action,
ActionContextItem,
// 组件类型
TextVariant,
ButtonVariant,
BadgeVariant,
AlertType,
Alignment,
Distribution,
Direction,
IconName,
// 组件定义
ComponentDef,
ComponentType,
ComponentTypeName,
RowComponent,
ColumnComponent,
CardComponent,
TextComponent,
IconComponent,
ButtonComponent,
BadgeComponent,
ProgressComponent,
TextFieldComponent,
SwitchComponent,
SelectComponent,
ListComponent,
TabsComponent,
AlertComponent,
SpinnerComponent,
EmptyComponent,
DividerComponent,
KeyValueComponent,
// 消息类型
ServerMessage,
SurfaceUpdate,
DataModelUpdate,
BeginRendering,
DeleteSurface,
ClientMessage,
UserAction,
ClientError,
DataEntry,
// 状态类型
SurfaceState,
SurfaceStyles,
// 渲染器类型
ComponentRenderer,
ComponentRendererProps,
ComponentRegistryEntry,
} from "./types";
// 核心模块导出
export { ComponentRegistry, componentRegistry } from "./ComponentRegistry";
export { SurfaceManager, surfaceManager } from "./SurfaceManager";
export {
DataStore,
dataEntriesToObject,
parsePath,
getValueByPath,
setValueByPath,
mergeDataUpdate,
} from "./DataStore";
// 渲染器导出
export { PluginUIRenderer } from "./PluginUIRenderer";
export { default as PluginUIRendererDefault } from "./PluginUIRenderer";
export { PluginUIContainer } from "./PluginUIContainer";
export { default as PluginUIContainerDefault } from "./PluginUIContainer";
// Hook 导出
export { usePluginUI, usePluginSurface } from "./usePluginUI";
// 组件导出
export {
standardComponents,
registerStandardComponents,
layoutComponents,
displayComponents,
inputComponents,
dataComponents,
} from "./components";
// 初始化函数
import { registerStandardComponents } from "./components";
/**
* 初始化插件 UI 系统
* 注册所有标准组件
*/
export function initPluginUI(): void {
registerStandardComponents();
}
+486
View File
@@ -0,0 +1,486 @@
/**
* @file ProxyCast Plugin UI 类型定义
* @description 基于 A2UI 设计理念的声明式插件 UI 系统类型
* @module lib/plugin-ui/types
*/
import type React from "react";
// ============================================================================
// 基础类型
// ============================================================================
/** 组件 ID */
export type ComponentId = string;
/** Surface ID */
export type SurfaceId = string;
/** 插件 ID */
export type PluginId = string;
/** 数据路径 (JSONPath 格式) */
export type DataPath = string;
// ============================================================================
// 数据绑定
// ============================================================================
/** 绑定值 - 支持字面值或数据路径绑定 */
export type BoundValue<T> =
| { literalString: string }
| { literalNumber: number }
| { literalBoolean: boolean }
| { literalArray: T[] }
| { path: DataPath }
| { literalString: string; path: DataPath }
| { literalNumber: number; path: DataPath }
| { literalBoolean: boolean; path: DataPath };
/** 字符串绑定值 */
export type BoundString = BoundValue<string>;
/** 数字绑定值 */
export type BoundNumber = BoundValue<number>;
/** 布尔绑定值 */
export type BoundBoolean = BoundValue<boolean>;
// ============================================================================
// 子组件定义
// ============================================================================
/** 子组件列表定义 */
export interface ChildrenDef {
/** 显式列表 - 固定的子组件 ID 列表 */
explicitList?: ComponentId[];
/** 模板 - 从数据列表动态生成子组件 */
template?: {
componentId: ComponentId;
dataBinding: DataPath;
};
}
// ============================================================================
// 操作定义
// ============================================================================
/** 操作上下文项 */
export interface ActionContextItem {
key: string;
value: BoundValue<unknown>;
}
/** 操作定义 */
export interface Action {
name: string;
context?: ActionContextItem[];
}
// ============================================================================
// 组件类型
// ============================================================================
/** 文本变体 */
export type TextVariant = "h1" | "h2" | "h3" | "h4" | "h5" | "body" | "caption";
/** 按钮变体 */
export type ButtonVariant =
| "default"
| "primary"
| "secondary"
| "destructive"
| "outline"
| "ghost";
/** Badge 变体 */
export type BadgeVariant = "default" | "success" | "warning" | "error" | "info";
/** Alert 类型 */
export type AlertType = "info" | "success" | "warning" | "error";
/** 对齐方式 */
export type Alignment = "start" | "center" | "end" | "stretch";
/** 分布方式 */
export type Distribution =
| "start"
| "center"
| "end"
| "spaceBetween"
| "spaceAround"
| "spaceEvenly";
/** 方向 */
export type Direction = "horizontal" | "vertical";
/** 图标名称 */
export type IconName =
| "add"
| "check"
| "close"
| "delete"
| "edit"
| "refresh"
| "search"
| "settings"
| "info"
| "warning"
| "error"
| "chevronDown"
| "chevronUp"
| "chevronLeft"
| "chevronRight"
| "power"
| "powerOff"
| "folder"
| "file"
| "copy"
| "play"
| "pause"
| "stop"
| "download"
| "upload";
// ============================================================================
// 标准组件定义
// ============================================================================
/** Row 组件 */
export interface RowComponent {
Row: {
children: ChildrenDef;
distribution?: Distribution;
alignment?: Alignment;
gap?: number;
};
}
/** Column 组件 */
export interface ColumnComponent {
Column: {
children: ChildrenDef;
distribution?: Distribution;
alignment?: Alignment;
gap?: number;
};
}
/** Card 组件 */
export interface CardComponent {
Card: {
child: ComponentId;
title?: BoundString;
description?: BoundString;
};
}
/** Text 组件 */
export interface TextComponent {
Text: {
text: BoundString;
variant?: TextVariant;
};
}
/** Icon 组件 */
export interface IconComponent {
Icon: {
name: BoundValue<IconName>;
size?: number;
color?: string;
};
}
/** Button 组件 */
export interface ButtonComponent {
Button: {
child: ComponentId;
action: Action;
variant?: ButtonVariant;
disabled?: BoundBoolean;
};
}
/** Badge 组件 */
export interface BadgeComponent {
Badge: {
text: BoundString;
variant?: BoundValue<BadgeVariant>;
};
}
/** Progress 组件 */
export interface ProgressComponent {
Progress: {
value: BoundNumber;
max?: number;
};
}
/** TextField 组件 */
export interface TextFieldComponent {
TextField: {
label: BoundString;
value: BoundString;
placeholder?: BoundString;
type?: "text" | "password" | "number" | "email";
disabled?: BoundBoolean;
};
}
/** Switch 组件 */
export interface SwitchComponent {
Switch: {
label: BoundString;
checked: BoundBoolean;
disabled?: BoundBoolean;
};
}
/** Select 组件 */
export interface SelectComponent {
Select: {
label?: BoundString;
value: BoundString;
options: Array<{
label: BoundString;
value: string;
}>;
disabled?: BoundBoolean;
};
}
/** List 组件 */
export interface ListComponent {
List: {
children: ChildrenDef;
direction?: Direction;
alignment?: Alignment;
gap?: number;
};
}
/** Tabs 组件 */
export interface TabsComponent {
Tabs: {
items: Array<{
id: string;
title: BoundString;
child: ComponentId;
}>;
defaultTab?: string;
};
}
/** Alert 组件 */
export interface AlertComponent {
Alert: {
message: BoundString;
type: AlertType;
title?: BoundString;
};
}
/** Spinner 组件 */
export interface SpinnerComponent {
Spinner: {
size?: number;
};
}
/** Empty 组件 */
export interface EmptyComponent {
Empty: {
icon?: IconName;
title?: BoundString;
description?: BoundString;
};
}
/** Divider 组件 */
export interface DividerComponent {
Divider: {
axis?: "horizontal" | "vertical";
};
}
/** KeyValue 组件 */
export interface KeyValueComponent {
KeyValue: {
items: Array<{
key: BoundString;
value: BoundString;
}>;
};
}
/** 所有组件类型联合 */
export type ComponentType =
| RowComponent
| ColumnComponent
| CardComponent
| TextComponent
| IconComponent
| ButtonComponent
| BadgeComponent
| ProgressComponent
| TextFieldComponent
| SwitchComponent
| SelectComponent
| ListComponent
| TabsComponent
| AlertComponent
| SpinnerComponent
| EmptyComponent
| DividerComponent
| KeyValueComponent;
/** 组件类型名称 */
export type ComponentTypeName = keyof (RowComponent &
ColumnComponent &
CardComponent &
TextComponent &
IconComponent &
ButtonComponent &
BadgeComponent &
ProgressComponent &
TextFieldComponent &
SwitchComponent &
SelectComponent &
ListComponent &
TabsComponent &
AlertComponent &
SpinnerComponent &
EmptyComponent &
DividerComponent &
KeyValueComponent);
// ============================================================================
// 组件定义
// ============================================================================
/** 组件定义 */
export interface ComponentDef {
id: ComponentId;
component: ComponentType;
weight?: number; // flex-grow 权重
}
// ============================================================================
// 消息类型 (Server → Client)
// ============================================================================
/** Surface 更新消息 */
export interface SurfaceUpdate {
surfaceId: SurfaceId;
components: ComponentDef[];
}
/** 数据条目 */
export interface DataEntry {
key: string;
valueString?: string;
valueNumber?: number;
valueBoolean?: boolean;
valueArray?: DataEntry[];
valueMap?: DataEntry[];
}
/** 数据模型更新消息 */
export interface DataModelUpdate {
surfaceId: SurfaceId;
path?: DataPath;
contents: DataEntry[];
}
/** Surface 样式 */
export interface SurfaceStyles {
primaryColor?: string;
font?: string;
borderRadius?: number;
}
/** 开始渲染消息 */
export interface BeginRendering {
surfaceId: SurfaceId;
root: ComponentId;
catalogId?: string;
styles?: SurfaceStyles;
}
/** 删除 Surface 消息 */
export interface DeleteSurface {
surfaceId: SurfaceId;
}
/** 服务端消息 */
export type ServerMessage =
| { surfaceUpdate: SurfaceUpdate }
| { dataModelUpdate: DataModelUpdate }
| { beginRendering: BeginRendering }
| { deleteSurface: DeleteSurface };
// ============================================================================
// 消息类型 (Client → Server)
// ============================================================================
/** 用户操作消息 */
export interface UserAction {
name: string;
surfaceId: SurfaceId;
sourceComponentId: ComponentId;
context: Record<string, unknown>;
timestamp: string;
}
/** 客户端错误消息 */
export interface ClientError {
surfaceId: SurfaceId;
message: string;
componentId?: ComponentId;
}
/** 客户端消息 */
export type ClientMessage = { userAction: UserAction } | { error: ClientError };
// ============================================================================
// Surface 状态
// ============================================================================
/** Surface 状态 */
export interface SurfaceState {
surfaceId: SurfaceId;
pluginId: PluginId;
rootId: ComponentId | null;
components: Map<ComponentId, ComponentDef>;
dataModel: Record<string, unknown>;
styles: SurfaceStyles;
isReady: boolean;
}
// ============================================================================
// 组件注册表
// ============================================================================
/** 组件渲染器 Props */
export interface ComponentRendererProps {
componentDef: ComponentDef;
surface: SurfaceState;
onAction: (action: UserAction) => void;
resolveValue: <T>(bound: BoundValue<T>, itemData?: unknown) => T | undefined;
renderChild: (childId: ComponentId, itemData?: unknown) => React.ReactNode;
renderChildren: (
children: ChildrenDef,
itemData?: unknown,
) => React.ReactNode[];
}
/** 组件渲染器类型 */
export type ComponentRenderer = React.FC<ComponentRendererProps>;
/** 组件注册表条目 */
export interface ComponentRegistryEntry {
renderer: ComponentRenderer;
schema?: Record<string, unknown>;
}
+203
View File
@@ -0,0 +1,203 @@
/**
* @file 插件 UI Hook
* @description 提供插件 UI 状态管理和消息处理的 React Hook
* @module lib/plugin-ui/usePluginUI
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { surfaceManager, SurfaceManager } from "./SurfaceManager";
import { initPluginUI } from "./index";
import type {
PluginId,
SurfaceId,
SurfaceState,
ServerMessage,
UserAction,
} from "./types";
/** Hook 配置选项 */
interface UsePluginUIOptions {
/** 插件 ID */
pluginId: PluginId;
/** 是否自动初始化 */
autoInit?: boolean;
/** 自定义 Surface 管理器 */
manager?: SurfaceManager;
}
/** Hook 返回值 */
interface UsePluginUIResult {
/** 插件的所有 Surface */
surfaces: SurfaceState[];
/** 是否正在加载 */
loading: boolean;
/** 错误信息 */
error: string | null;
/** 处理用户操作 */
handleAction: (action: UserAction) => Promise<void>;
/** 刷新 Surface */
refresh: () => Promise<void>;
}
/**
* 插件 UI Hook
* 管理插件的 UI Surface 状态
*/
export function usePluginUI(options: UsePluginUIOptions): UsePluginUIResult {
const { pluginId, autoInit = true, manager = surfaceManager } = options;
const [surfaces, setSurfaces] = useState<SurfaceState[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const initializedRef = useRef(false);
// 初始化
useEffect(() => {
if (!initializedRef.current) {
initPluginUI();
initializedRef.current = true;
}
}, []);
// 订阅 Surface 变化
useEffect(() => {
const unsubscribe = manager.subscribe((allSurfaces) => {
const pluginSurfaces = Array.from(allSurfaces.values()).filter(
(s) => s.pluginId === pluginId,
);
setSurfaces(pluginSurfaces);
});
return unsubscribe;
}, [pluginId, manager]);
// 监听来自 Rust 的 UI 消息
useEffect(() => {
let unlisten: UnlistenFn | null = null;
const setupListener = async () => {
try {
unlisten = await listen<{ pluginId: string; message: ServerMessage }>(
"plugin-ui-message",
(event) => {
if (event.payload.pluginId === pluginId) {
manager.processMessage(pluginId, event.payload.message);
}
},
);
} catch (err) {
console.error("[usePluginUI] 监听事件失败:", err);
}
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
}, [pluginId, manager]);
// 加载初始 UI
const loadInitialUI = useCallback(async () => {
setLoading(true);
setError(null);
try {
// 调用 Rust 获取插件的初始 UI 定义
const messages = await invoke<ServerMessage[]>("get_plugin_ui", {
pluginId,
});
// 处理所有消息
for (const message of messages) {
manager.processMessage(pluginId, message);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage);
console.error("[usePluginUI] 加载 UI 失败:", err);
} finally {
setLoading(false);
}
}, [pluginId, manager]);
// 自动初始化
useEffect(() => {
if (autoInit) {
loadInitialUI();
}
}, [autoInit, loadInitialUI]);
// 处理用户操作
const handleAction = useCallback(
async (action: UserAction) => {
try {
// 特殊处理数据更新操作
if (action.name === "__data_update__") {
const { path, value } = action.context as {
path: string;
value: unknown;
};
const dataStore = manager.getDataStore(action.surfaceId);
if (dataStore) {
dataStore.setValue(path, value);
}
return;
}
// 发送操作到 Rust
const responses = await invoke<ServerMessage[]>(
"handle_plugin_action",
{
pluginId,
action,
},
);
// 处理响应消息
for (const message of responses) {
manager.processMessage(pluginId, message);
}
} catch (err) {
console.error("[usePluginUI] 处理操作失败:", err);
setError(err instanceof Error ? err.message : String(err));
}
},
[pluginId, manager],
);
// 刷新
const refresh = useCallback(async () => {
manager.clearPlugin(pluginId);
await loadInitialUI();
}, [pluginId, manager, loadInitialUI]);
return {
surfaces,
loading,
error,
handleAction,
refresh,
};
}
/**
* 单个 Surface 的 Hook
*/
export function usePluginSurface(
pluginId: PluginId,
surfaceId: SurfaceId,
): {
surface: SurfaceState | undefined;
handleAction: (action: UserAction) => Promise<void>;
} {
const { surfaces, handleAction } = usePluginUI({ pluginId });
const surface = surfaces.find((s) => s.surfaceId === surfaceId);
return { surface, handleAction };
}