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