diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3ff8a3ae..0362d6175 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/docs/plugin-ui-design.md b/docs/plugin-ui-design.md new file mode 100644 index 000000000..902a95649 --- /dev/null +++ b/docs/plugin-ui-design.md @@ -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; // 组件缓冲区 + dataModel: Record; // 数据模型 + 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' }, + Tabs: { items: 'TabItem[]' }, + + // 展示组件 + Text: { text: 'BoundValue', variant?: 'TextVariant' }, + Icon: { name: 'IconName', size?: 'number', color?: 'string' }, + Badge: { text: 'BoundValue', variant?: 'BadgeVariant' }, + Progress: { value: 'BoundValue', max?: 'number' }, + + // 输入组件 + Button: { child: 'ComponentRef', action: 'Action', variant?: 'ButtonVariant' }, + TextField: { label: 'BoundValue', value: 'BoundValue' }, + Switch: { label: 'BoundValue', checked: 'BoundValue' }, + Select: { options: 'SelectOption[]', value: 'BoundValue' }, + + // 数据展示 + Table: { columns: 'TableColumn[]', data: 'BoundValue' }, + List: { children: 'ChildrenDef', direction?: 'Direction' }, + KeyValue: { items: 'KeyValueItem[]' }, + + // 反馈组件 + Alert: { message: 'BoundValue', type: 'AlertType' }, + Spinner: { size?: 'number' }, + Empty: { description?: 'BoundValue' }, +}; +``` + +### 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; // 解析后的上下文数据 + timestamp: string; +} +``` + +### 4. 数据绑定 + +支持字面值和路径绑定: + +```typescript +type BoundValue = + | { 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; + + /// 处理用户操作 + fn handle_action(&mut self, action: UserAction) -> Result>; +} + +/// UI 消息类型 +pub enum UIMessage { + SurfaceUpdate(SurfaceUpdate), + DataModelUpdate(DataModelUpdate), + BeginRendering(BeginRendering), + DeleteSurface(DeleteSurface), +} + +/// Surface 定义 +pub struct SurfaceDefinition { + pub surface_id: String, + pub initial_components: Vec, + pub initial_data: serde_json::Value, + pub root_id: String, +} +``` + +## 使用示例 + +### 插件端(Rust) + +```rust +impl PluginUI for CredentialMonitorPlugin { + fn get_surfaces(&self) -> Vec { + 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> { + 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 ( +
+ + + {/* 插件 UI 渲染区域 */} + invoke('plugin_handle_action', { pluginId, action })} + /> +
+ ); +} +``` + +## 安全考虑 + +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 { + 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, PluginError> { + match action.name.as_str() { + "refresh" => { + // 返回数据更新消息 + Ok(vec![UIMessage::DataModelUpdate(/* ... */)]) + } + _ => Ok(vec![]) + } + } +} +``` + +## 下一步计划 + +1. **更多组件**:Table、Tabs、Modal 等复杂组件 +2. **表单验证**:支持 TextField 的验证规则 +3. **主题系统**:更完善的样式定制能力 +4. **插件市场**:支持从远程加载插件 UI 定义 diff --git a/package.json b/package.json index 9e741f8a3..2d9850a7f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.21.0", + "version": "0.22.0", "type": "module", "repository": { "type": "git", diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 000000000..60e29fb15 --- /dev/null +++ b/plugins/README.md @@ -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) diff --git a/plugins/machine-id-tool/config.json b/plugins/machine-id-tool/config.json new file mode 100644 index 000000000..56227b8a2 --- /dev/null +++ b/plugins/machine-id-tool/config.json @@ -0,0 +1,4 @@ +{ + "enabled": true, + "settings": {} +} diff --git a/plugins/machine-id-tool/plugin.json b/plugins/machine-id-tool/plugin.json new file mode 100644 index 000000000..129f2265e --- /dev/null +++ b/plugins/machine-id-tool/plugin.json @@ -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 + } +} diff --git a/scripts/build-plugin.sh b/scripts/build-plugin.sh new file mode 100755 index 000000000..4bd9606df --- /dev/null +++ b/scripts/build-plugin.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# 插件包构建脚本 +# 用法: ./scripts/build-plugin.sh [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 [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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 066ad139e..8d28cc2f7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cffbc73f3..7adb3a44a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 872262f8a..897aaf6f7 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -18,7 +18,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。 - `injection/` - 请求注入(系统提示词等) - `middleware/` - HTTP 中间件 - `models/` - 数据模型定义 -- `plugin/` - 插件系统 +- `plugin/` - 插件系统(含声明式 UI 系统) - `processor/` - 请求处理管道 - `providers/` - 各 Provider 的认证和 API 实现 - `proxy/` - HTTP 代理客户端 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index de5bce47d..617731c4b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/plugin_cmd.rs b/src-tauri/src/commands/plugin_cmd.rs index 7c2c0d3e3..61a50f617 100644 --- a/src-tauri/src/commands/plugin_cmd.rs +++ b/src-tauri/src/commands/plugin_cmd.rs @@ -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>); @@ -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, +} + +/// 从插件目录读取 manifest 文件 +/// +/// 尝试读取 plugin.json 文件并解析为 PluginManifest +fn read_plugin_manifest(install_path: &Path) -> Option { + 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, 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 = 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, 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 = 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, String> { + let mut manager = state.0.write().await; + + manager + .handle_plugin_action(&plugin_id, action) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/plugin_install_cmd.rs b/src-tauri/src/commands/plugin_install_cmd.rs new file mode 100644 index 000000000..07d8512ff --- /dev/null +++ b/src-tauri/src/commands/plugin_install_cmd.rs @@ -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>); + +/// 安装结果响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstallResult { + pub success: bool, + pub plugin: Option, + pub error: Option, +} + +/// 进度事件名称 +const INSTALL_PROGRESS_EVENT: &str = "plugin-install-progress"; + +/// Tauri 进度回调实现 +/// +/// 将安装进度通过 Tauri 事件发送到前端 +struct TauriProgressCallback { + app_handle: AppHandle, +} + +impl TauriProgressCallback { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +impl crate::plugin::installer::ProgressCallback for TauriProgressCallback { + 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( + app_handle: AppHandle, + state: tauri::State<'_, PluginInstallerState>, + file_path: String, +) -> Result { + 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( + app_handle: AppHandle, + state: tauri::State<'_, PluginInstallerState>, + url: String, +) -> Result { + 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 { + 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, 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, 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 { + let installer = state.0.read().await; + installer + .is_installed(&plugin_id) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/database/dao/installed_plugins.rs b/src-tauri/src/database/dao/installed_plugins.rs new file mode 100644 index 000000000..197d76052 --- /dev/null +++ b/src-tauri/src/database/dao/installed_plugins.rs @@ -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, + /// 作者 + pub author: Option, + /// 安装路径 + pub install_path: PathBuf, + /// 安装时间 + pub installed_at: DateTime, + /// 安装来源 + pub source: InstallSource, + /// 是否启用 + pub enabled: bool, +} + +/// 数据库行结构 +struct PluginRow { + id: String, + name: String, + version: String, + description: Option, + author: Option, + install_path: String, + installed_at: String, + source_type: String, + source_data: Option, + enabled: i32, +} + +impl PluginRow { + fn into_record(self) -> Result { + 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) { + 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 { + 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 { + 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, 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, 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 { + 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 { + 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"), + } + } +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 87198d2ff..13aef57fa 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -1,3 +1,4 @@ +pub mod installed_plugins; pub mod mcp; pub mod prompts; pub mod provider_pool; diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 381c47a2b..999c4931e 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -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(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f8731ef85..08f3c3cb7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/plugin/README.md b/src-tauri/src/plugin/README.md new file mode 100644 index 000000000..c7a727fa3 --- /dev/null +++ b/src-tauri/src/plugin/README.md @@ -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, +}; +``` + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/src/plugin/examples/credential_monitor.rs b/src-tauri/src/plugin/examples/credential_monitor.rs new file mode 100644 index 000000000..549fb6e1a --- /dev/null +++ b/src-tauri/src/plugin/examples/credential_monitor.rs @@ -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, +} + +/// 凭证信息 +#[derive(Debug, Clone)] +struct CredentialInfo { + id: String, + name: String, + provider: String, + status: String, + last_used: Option, +} + +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 { + 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 = 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 { + 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, 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()); + } +} diff --git a/src-tauri/src/plugin/examples/mod.rs b/src-tauri/src/plugin/examples/mod.rs new file mode 100644 index 000000000..68159357a --- /dev/null +++ b/src-tauri/src/plugin/examples/mod.rs @@ -0,0 +1,7 @@ +//! 示例插件模块 +//! +//! 提供插件 UI 系统的使用示例 + +pub mod credential_monitor; + +pub use credential_monitor::CredentialMonitorPlugin; diff --git a/src-tauri/src/plugin/installer/README.md b/src-tauri/src/plugin/installer/README.md new file mode 100644 index 000000000..bf4f5c3f8 --- /dev/null +++ b/src-tauri/src/plugin/installer/README.md @@ -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)?; +``` diff --git a/src-tauri/src/plugin/installer/downloader.rs b/src-tauri/src/plugin/installer/downloader.rs new file mode 100644 index 000000000..5206038c2 --- /dev/null +++ b/src-tauri/src/plugin/installer/downloader.rs @@ -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 { + // 格式 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 { + // 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 { + // 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 { + 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 { + 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>>, + } + + impl ProgressCollector { + fn new() -> Self { + Self { + progresses: Arc::new(Mutex::new(Vec::new())), + } + } + + fn get_progresses(&self) -> Vec { + 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 { + ( + "[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 { + ( + "[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 { + ( + "[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 { + ( + "[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"); + } +} diff --git a/src-tauri/src/plugin/installer/installer.rs b/src-tauri/src/plugin/installer/installer.rs new file mode 100644 index 000000000..09666a3b1 --- /dev/null +++ b/src-tauri/src/plugin/installer/installer.rs @@ -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>) -> 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 { + 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 { + // 阶段 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 { + // 确保临时目录存在 + 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, InstallError> { + self.registry.list() + } + + /// 获取插件信息 + pub fn get_plugin(&self, plugin_id: &str) -> Result, InstallError> { + self.registry.get(plugin_id) + } + + /// 检查插件是否已安装 + pub fn is_installed(&self, plugin_id: &str) -> Result { + self.registry.exists(plugin_id) + } + + /// 解压插件包到临时目录 + fn extract_package( + &self, + path: &Path, + format: PackageFormat, + progress: &dyn ProgressCallback, + ) -> Result { + // 创建临时解压目录 + 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 { + // 首先检查根目录 + 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 { + 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 { + "[a-z][a-z0-9_-]{2,20}".prop_map(|s| s) + } + + /// 生成有效的版本号 + fn arb_valid_version() -> impl Strategy { + (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(()) + })?; + } + } +} diff --git a/src-tauri/src/plugin/installer/mod.rs b/src-tauri/src/plugin/installer/mod.rs new file mode 100644 index 000000000..41fae142e --- /dev/null +++ b/src-tauri/src/plugin/installer/mod.rs @@ -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; diff --git a/src-tauri/src/plugin/installer/registry.rs b/src-tauri/src/plugin/installer/registry.rs new file mode 100644 index 000000000..ee30d3276 --- /dev/null +++ b/src-tauri/src/plugin/installer/registry.rs @@ -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>, +} + +impl PluginRegistry { + /// 创建新的注册表实例 + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + /// 从数据库路径创建注册表 + pub fn from_path(db_path: &Path) -> Result { + 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, 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, 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 { + 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, + author: Option, + install_path: String, + installed_at: String, + source_type: String, + source_data: Option, + enabled: i32, +} + +impl PluginRow { + fn into_installed_plugin(self) -> Result { + 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) { + 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 { + 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"), + } + } +} diff --git a/src-tauri/src/plugin/installer/types.rs b/src-tauri/src/plugin/installer/types.rs new file mode 100644 index 000000000..a416bc024 --- /dev/null +++ b/src-tauri/src/plugin/installer/types.rs @@ -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) -> Self { + Self { + stage, + percent: percent.min(100), + message: message.into(), + } + } + + /// 创建下载阶段进度 + pub fn downloading(percent: u8, message: impl Into) -> Self { + Self::new(InstallStage::Downloading, percent, message) + } + + /// 创建验证阶段进度 + pub fn validating(message: impl Into) -> Self { + Self::new(InstallStage::Validating, 0, message) + } + + /// 创建解压阶段进度 + pub fn extracting(percent: u8, message: impl Into) -> Self { + Self::new(InstallStage::Extracting, percent, message) + } + + /// 创建安装阶段进度 + pub fn installing(percent: u8, message: impl Into) -> Self { + Self::new(InstallStage::Installing, percent, message) + } + + /// 创建注册阶段进度 + pub fn registering(message: impl Into) -> Self { + Self::new(InstallStage::Registering, 90, message) + } + + /// 创建完成状态 + pub fn complete(message: impl Into) -> Self { + Self::new(InstallStage::Complete, 100, message) + } + + /// 创建失败状态 + pub fn failed(message: impl Into) -> 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 +where + F: Fn(InstallProgress) + Send + Sync, +{ + callback: F, +} + +impl FnProgressCallback +where + F: Fn(InstallProgress) + Send + Sync, +{ + /// 创建新的函数回调 + pub fn new(callback: F) -> Self { + Self { callback } + } +} + +impl ProgressCallback for FnProgressCallback +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 { + 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, +} + +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, + /// 安装路径 + pub install_path: PathBuf, + /// 安装时间 + pub installed_at: DateTime, + /// 安装来源 + pub source: InstallSource, + /// 是否启用 + pub enabled: bool, +} + +impl InstalledPlugin { + /// 创建新的已安装插件信息 + pub fn new( + id: impl Into, + name: impl Into, + version: impl Into, + description: impl Into, + 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) -> 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"), + } + } +} diff --git a/src-tauri/src/plugin/installer/validator.rs b/src-tauri/src/plugin/installer/validator.rs new file mode 100644 index 000000000..0bf2b1e6e --- /dev/null +++ b/src-tauri/src/plugin/installer/validator.rs @@ -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 { + // 首先检查文件是否存在 + 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::().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 { + 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 { + 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 { + 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 { + "[a-zA-Z][a-zA-Z0-9_-]{0,30}".prop_map(|s| s) + } + + /// 生成有效的版本号 + fn arb_valid_version() -> impl Strategy { + (1u32..100, 0u32..100, 0u32..100) + .prop_map(|(major, minor, patch)| format!("{}.{}.{}", major, minor, patch)) + } + + /// 生成有效的插件类型 + fn arb_plugin_type() -> impl Strategy { + prop_oneof![Just("script"), Just("native"), Just("binary"),] + } + + /// 生成有效的钩子名称 + fn arb_valid_hook() -> impl Strategy { + "[a-zA-Z][a-zA-Z0-9_:]{0,20}".prop_map(|s| s) + } + + /// 生成有效的 PluginManifest JSON + fn arb_valid_manifest_json() -> impl Strategy { + ( + arb_valid_plugin_name(), + arb_valid_version(), + arb_plugin_type(), + prop::collection::vec(arb_valid_hook(), 0..3), + any::>().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::>() + .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"); + } + } +} diff --git a/src-tauri/src/plugin/manager.rs b/src-tauri/src/plugin/manager.rs index c6f58965c..f529f3679 100644 --- a/src-tauri/src/plugin/manager.rs +++ b/src-tauri/src/plugin/manager.rs @@ -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, 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, 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 { diff --git a/src-tauri/src/plugin/mod.rs b/src-tauri/src/plugin/mod.rs index 73c7d954a..a8900beb5 100644 --- a/src-tauri/src/plugin/mod.rs +++ b/src-tauri/src/plugin/mod.rs @@ -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; diff --git a/src-tauri/src/plugin/tests.rs b/src-tauri/src/plugin/tests.rs index d009faea4..6d584aecc 100644 --- a/src-tauri/src/plugin/tests.rs +++ b/src-tauri/src/plugin/tests.rs @@ -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, }; // 序列化 diff --git a/src-tauri/src/plugin/types.rs b/src-tauri/src/plugin/types.rs index db4e94f61..1aa6c60ba 100644 --- a/src-tauri/src/plugin/types.rs +++ b/src-tauri/src/plugin/types.rs @@ -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, + /// Binary 类型插件的扩展配置 + /// _需求: 5.2_ + #[serde(default)] + pub binary: Option, + /// UI 配置 + /// _需求: 5.3_ + #[serde(default)] + pub ui: Option, } 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, } +/// UI 配置扩展字段 +/// +/// 定义插件的 UI 展示配置 +/// _需求: 5.2, 5.3_ +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UiManifest { + /// UI 展示位置 (如 "main", "settings", "sidebar") + #[serde(default)] + pub surfaces: Vec, + /// 图标名称 (使用 Lucide 图标名) + #[serde(default)] + pub icon: Option, + /// 窗口标题 + #[serde(default)] + pub title: Option, + /// 窗口默认宽度 + #[serde(default)] + pub default_width: Option, + /// 窗口默认高度 + #[serde(default)] + pub default_height: Option, +} + /// 二进制组件状态 #[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 { + ( + "[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 { + ( + "[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 { + ( + 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 { + prop_oneof![ + Just(PluginType::Script), + Just(PluginType::Native), + Just(PluginType::Binary), + ] + } + + /// 生成随机的 PluginManifest + /// + /// 用于属性测试,生成包含所有字段的完整清单 + fn arb_plugin_manifest() -> impl Strategy { + ( + "[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"); + } +} diff --git a/src-tauri/src/plugin/ui_builder.rs b/src-tauri/src/plugin/ui_builder.rs new file mode 100644 index 000000000..881028304 --- /dev/null +++ b/src-tauri/src/plugin/ui_builder.rs @@ -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, + data: serde_json::Value, + styles: Option, +} + +impl SurfaceBuilder { + /// 创建新的 Surface 构建器 + pub fn new(surface_id: impl Into, root_id: impl Into) -> 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) -> 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, children: ChildrenDef) -> Self { + Self::new( + id, + ComponentType::Row(RowProps { + children, + distribution: None, + alignment: None, + gap: None, + }), + ) + } + + /// 创建 Column 组件 + pub fn column(id: impl Into, children: ChildrenDef) -> Self { + Self::new( + id, + ComponentType::Column(ColumnProps { + children, + distribution: None, + alignment: None, + gap: None, + }), + ) + } + + /// 创建 Card 组件 + pub fn card(id: impl Into, child: impl Into) -> Self { + Self::new( + id, + ComponentType::Card(CardProps { + child: child.into(), + title: None, + description: None, + }), + ) + } + + /// 创建 Text 组件 + pub fn text(id: impl Into, text: BoundValue) -> Self { + Self::new( + id, + ComponentType::Text(TextProps { + text, + variant: None, + }), + ) + } + + /// 创建 Text 组件(字面值) + pub fn text_literal(id: impl Into, text: impl Into) -> Self { + Self::text(id, BoundValue::string(text)) + } + + /// 创建 Text 组件(路径绑定) + pub fn text_bound(id: impl Into, path: impl Into) -> Self { + Self::text(id, BoundValue::path(path)) + } + + /// 创建 Icon 组件 + pub fn icon(id: impl Into, name: impl Into) -> Self { + Self::new( + id, + ComponentType::Icon(IconProps { + name: BoundValue::string(name), + size: None, + color: None, + }), + ) + } + + /// 创建 Button 组件 + pub fn button(id: impl Into, child: impl Into, action: Action) -> Self { + Self::new( + id, + ComponentType::Button(ButtonProps { + child: child.into(), + action, + variant: None, + disabled: None, + }), + ) + } + + /// 创建 Badge 组件 + pub fn badge(id: impl Into, text: BoundValue) -> Self { + Self::new( + id, + ComponentType::Badge(BadgeProps { + text, + variant: None, + }), + ) + } + + /// 创建 List 组件 + pub fn list(id: impl Into, children: ChildrenDef) -> Self { + Self::new( + id, + ComponentType::List(ListProps { + children, + direction: None, + alignment: None, + gap: None, + }), + ) + } + + /// 创建 Alert 组件 + pub fn alert(id: impl Into, message: BoundValue, alert_type: AlertType) -> Self { + Self::new( + id, + ComponentType::Alert(AlertProps { + message, + alert_type, + title: None, + }), + ) + } + + /// 创建 Spinner 组件 + pub fn spinner(id: impl Into) -> Self { + Self::new(id, ComponentType::Spinner(SpinnerProps::default())) + } + + /// 创建 Empty 组件 + pub fn empty(id: impl Into) -> Self { + Self::new(id, ComponentType::Empty(EmptyProps::default())) + } + + /// 创建 Divider 组件 + pub fn divider(id: impl Into) -> 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 + } +} diff --git a/src-tauri/src/plugin/ui_events.rs b/src-tauri/src/plugin/ui_events.rs new file mode 100644 index 000000000..c06480a70 --- /dev/null +++ b/src-tauri/src/plugin/ui_events.rs @@ -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) -> 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); + +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() + } +} diff --git a/src-tauri/src/plugin/ui_trait.rs b/src-tauri/src/plugin/ui_trait.rs new file mode 100644 index 000000000..9778d6540 --- /dev/null +++ b/src-tauri/src/plugin/ui_trait.rs @@ -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; + + /// 处理用户操作 + /// 返回需要发送给前端的 UI 消息列表 + async fn handle_action(&mut self, action: UserAction) -> Result, 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 { + Vec::new() + } + + async fn handle_action(&mut self, _action: UserAction) -> Result, PluginError> { + Ok(Vec::new()) + } + + fn has_ui(&self) -> bool { + false + } +} diff --git a/src-tauri/src/plugin/ui_types.rs b/src-tauri/src/plugin/ui_types.rs new file mode 100644 index 000000000..8876b30e5 --- /dev/null +++ b/src-tauri/src/plugin/ui_types.rs @@ -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) -> 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) -> 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>, + /// 模板 - 从数据列表动态生成子组件 + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, +} + +/// 模板定义 +#[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>) -> Self { + Self { + explicit_list: Some(ids.into_iter().map(|s| s.into()).collect()), + template: None, + } + } + + /// 创建模板 + pub fn template(component_id: impl Into, data_binding: impl Into) -> 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>, +} + +impl Action { + /// 创建简单操作 + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + context: None, + } + } + + /// 添加上下文 + pub fn with_context(mut self, key: impl Into, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub alignment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gap: Option, +} + +/// Column 组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ColumnProps { + pub children: ChildrenDef, + #[serde(skip_serializing_if = "Option::is_none")] + pub distribution: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alignment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gap: Option, +} + +/// Card 组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CardProps { + pub child: ComponentId, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Text 组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextProps { + pub text: BoundValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub variant: Option, +} + +/// Icon 组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IconProps { + pub name: BoundValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled: Option, +} + +/// Badge 组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BadgeProps { + pub text: BoundValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub variant: Option, +} + +/// Progress 组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgressProps { + pub value: BoundValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub max: Option, +} + +/// TextField 组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextFieldProps { + pub label: BoundValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled: Option, +} + +/// 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, +} + +/// List 组件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListProps { + pub children: ChildrenDef, + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alignment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gap: Option, +} + +/// 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, +} + +/// Spinner 组件 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SpinnerProps { + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +/// Empty 组件 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EmptyProps { + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Divider 组件 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DividerProps { + #[serde(skip_serializing_if = "Option::is_none")] + pub axis: Option, +} + +/// 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, +} + +/// 组件类型枚举 +#[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, +} + +impl ComponentDef { + /// 创建组件定义 + pub fn new(id: impl Into, 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, +} + +/// 数据条目 +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_boolean: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_array: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_map: Option>, +} + +impl DataEntry { + /// 创建字符串条目 + pub fn string(key: impl Into, value: impl Into) -> 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, 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, 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, entries: Vec) -> 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, + pub contents: Vec, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub font: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub border_radius: Option, +} + +/// 开始渲染消息 +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub styles: Option, +} + +/// 删除 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, + 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, + /// 初始数据 + pub initial_data: serde_json::Value, + /// 样式 + #[serde(skip_serializing_if = "Option::is_none")] + pub styles: Option, +} + +impl SurfaceDefinition { + /// 转换为 UI 消息列表 + pub fn to_messages(&self) -> Vec { + 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 = 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 { + 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 = 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 = 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, + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 824cd835a..0a15f1899 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 7e14cd8ec..a8129fa08 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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("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 ( + + ); + } + + // 静态页面路由 switch (currentPage) { case "provider-pool": return ; @@ -56,8 +92,6 @@ function App() { return ; case "browser-interceptor": return ; - case "machine-id": - return ; case "settings": return ; default: diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 71e507513..21a00bc6d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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; diff --git a/src/components/plugins/PluginInstallDialog.tsx b/src/components/plugins/PluginInstallDialog.tsx new file mode 100644 index 000000000..0f2376989 --- /dev/null +++ b/src/components/plugins/PluginInstallDialog.tsx @@ -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(null); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [autoInstallTriggered, setAutoInstallTriggered] = useState(false); + + // 监听安装进度事件 + useEffect(() => { + if (!isOpen) return; + + const unlisten = listen( + "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( + "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( + "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 ( +
+
+ + {isComplete ? ( + + ) : isFailed ? ( + + ) : ( + + )} + {getStageText(progress.stage)} + + {progress.percent}% +
+ +

{progress.message}

+
+ ); + }; + + // 渲染安装结果 + const renderResult = () => { + if (!result) return null; + + return ( +
+
+ +
+

+ 安装成功 +

+
+

+ 插件名称: + {result.name} +

+

+ 版本: + {result.version} +

+ {result.description && ( +

+ 描述: + {result.description} +

+ )} +
+
+
+
+ ); + }; + + return ( + + 安装插件 + + + {/* 安装结果显示 */} + {result ? ( + renderResult() + ) : ( + <> + {/* 安装方式选择 */} + setActiveTab(v as "file" | "url")} + > + + + + 本地文件 + + + + URL 下载 + + + + {/* 本地文件安装 */} + +
+ +
+ setFilePath(e.target.value)} + placeholder="选择 .zip 或 .tar.gz 文件..." + disabled={installing} + className="flex-1" + /> + +
+

+ 支持 .zip 和 .tar.gz 格式的插件包 +

+
+
+ + {/* URL 安装 */} + +
+ + setUrl(e.target.value)} + placeholder="https://github.com/.../releases/download/..." + disabled={installing} + /> +

+ 支持 GitHub Releases 或其他直接下载链接 +

+
+
+
+ + {/* 进度显示 */} + {installing && progress && ( +
{renderProgress()}
+ )} + + {/* 错误显示 */} + {error && ( +
+
+ + {error} +
+
+ )} + + )} +
+ + + {result ? ( + + ) : ( + <> + + + + )} + +
+ ); +} diff --git a/src/components/plugins/PluginManager.tsx b/src/components/plugins/PluginManager.tsx index 84621a3b9..fb88799aa 100644 --- a/src/components/plugins/PluginManager.tsx +++ b/src/components/plugins/PluginManager.tsx @@ -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(null); const [plugins, setPlugins] = useState([]); + const [installedPlugins, setInstalledPlugins] = useState( + [], + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedPlugin, setExpandedPlugin] = useState(null); - const fetchData = async () => { + // 对话框状态 + const [showInstallDialog, setShowInstallDialog] = useState(false); + const [pluginToUninstall, setPluginToUninstall] = + useState(null); + const [pendingInstallUrl, setPendingInstallUrl] = useState( + null, + ); + + const fetchData = useCallback(async () => { try { setLoading(true); - const [serviceStatus, pluginList] = await Promise.all([ + const [serviceStatus, pluginList, installedList] = await Promise.all([ invoke("get_plugin_status"), invoke("get_plugins"), + invoke("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() { 插件系统
+ +
+ + ))} + + + )} + + {/* 已安装插件列表(通过安装器安装的) */} + {installedPlugins.length > 0 && ( +
+
+

+ + 已安装插件包 +

+
+
+ {installedPlugins.map((plugin) => ( + setPluginToUninstall(plugin)} + /> + ))} +
+
+ )} + {/* 插件列表 */}
-

已安装插件

+

已加载插件

{plugins.length === 0 ? (
-

暂无已安装的插件

-

将插件放入插件目录即可自动加载

+

暂无已加载的插件

+

点击"安装插件"按钮添加新插件

) : (
@@ -232,6 +404,25 @@ export function PluginManager() {
)}
+ + {/* 安装对话框 */} + { + setShowInstallDialog(false); + setPendingInstallUrl(null); + }} + onSuccess={handleInstallSuccess} + initialUrl={pendingInstallUrl || undefined} + /> + + {/* 卸载确认对话框 */} + setPluginToUninstall(null)} + onSuccess={fetchData} + /> ); } @@ -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 ( +
+
+
+
+ {plugin.name} + + v{plugin.version} + + {plugin.enabled ? ( + + + 已启用 + + ) : ( + + + 已禁用 + + )} +
+
+ {plugin.description || "无描述"} +
+
+ {getSourceText(plugin.source)} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/plugins/PluginUIRenderer.test.tsx b/src/components/plugins/PluginUIRenderer.test.tsx new file mode 100644 index 000000000..23a381a33 --- /dev/null +++ b/src/components/plugins/PluginUIRenderer.test.tsx @@ -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 }) => ( +
MachineIdTool Mock
+ ), +})); + +// Mock lucide-react icons +vi.mock("lucide-react", () => ({ + AlertCircle: () => AlertCircle, + Package: () => Package, +})); + +describe("PluginUIRenderer", () => { + const mockNavigate = vi.fn(); + + beforeEach(() => { + mockNavigate.mockClear(); + }); + + describe("内置插件组件渲染", () => { + it("应该正确渲染 machine-id-tool 插件", () => { + const { container } = renderComponent( + , + ); + + // 验证 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( + , + ); + + // 验证显示插件未找到提示 + expect(container.textContent).toContain("插件未找到"); + expect(container.textContent).toContain("unknown-plugin"); + expect(container.textContent).toContain("未安装或不存在"); + }); + + it("应该为空字符串 pluginId 显示 '插件未找到' 提示", () => { + const { container } = renderComponent( + , + ); + + // 验证显示插件未找到提示 + expect(container.textContent).toContain("插件未找到"); + }); + + it("应该为随机 pluginId 显示 '插件未找到' 提示", () => { + const randomPluginId = `random-plugin-${Date.now()}`; + const { container } = renderComponent( + , + ); + + // 验证显示插件未找到提示,并包含插件 ID + expect(container.textContent).toContain("插件未找到"); + expect(container.textContent).toContain(randomPluginId); + }); + }); + + describe("插件 ID 大小写敏感性", () => { + it("应该区分大小写 - 'Machine-Id-Tool' 应该显示未找到", () => { + const { container } = renderComponent( + , + ); + + // 验证大小写不匹配时显示未找到 + expect(container.textContent).toContain("插件未找到"); + }); + + it("应该区分大小写 - 'MACHINE-ID-TOOL' 应该显示未找到", () => { + const { container } = renderComponent( + , + ); + + // 验证大小写不匹配时显示未找到 + 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(); + }, + }; +} diff --git a/src/components/plugins/PluginUIRenderer.tsx b/src/components/plugins/PluginUIRenderer.tsx new file mode 100644 index 000000000..35cbba622 --- /dev/null +++ b/src/components/plugins/PluginUIRenderer.tsx @@ -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 ( +
+
+ +
+
+

+ 插件 UI 加载失败 +

+

+ 无法加载插件 "{pluginId}" 的用户界面 +

+

{error}

+
+
+ ); +} + +/** + * 插件未找到组件 + * + * 当请求的插件不存在时显示提示 + */ +function PluginNotFound({ pluginId }: { pluginId: string }) { + return ( +
+
+ +
+
+

+ 插件未找到 +

+

+ 插件 "{pluginId}" 未安装或不存在 +

+

+ 请检查插件是否已正确安装 +

+
+
+ ); +} + +/** + * 内置插件组件映射 + * + * 将插件 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 ; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "未知错误"; + return ; + } + } + + // 插件未找到 + return ; +} + +export default PluginUIRenderer; diff --git a/src/components/plugins/PluginUninstallDialog.tsx b/src/components/plugins/PluginUninstallDialog.tsx new file mode 100644 index 000000000..992227529 --- /dev/null +++ b/src/components/plugins/PluginUninstallDialog.tsx @@ -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(null); + + // 关闭对话框 + const handleClose = () => { + if (uninstalling) return; + setError(null); + onClose(); + }; + + // 执行卸载 + const handleUninstall = async () => { + if (!plugin) return; + + setUninstalling(true); + setError(null); + + try { + await invoke("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 ( + + +
+
+ +
+
+

确认卸载插件

+
+

+ 确定要卸载插件{" "} + + {plugin.name} + {" "} + 吗? +

+

此操作将删除插件文件和相关配置,无法撤销。

+
+ + {/* 插件信息 */} +
+
+

+ 版本: + {plugin.version} +

+ {plugin.description && ( +

+ 描述: + {plugin.description} +

+ )} +
+
+ + {/* 错误显示 */} + {error && ( +
+ {error} +
+ )} +
+
+
+ + + + + +
+ ); +} diff --git a/src/components/plugins/README.md b/src/components/plugins/README.md new file mode 100644 index 000000000..58bc74855 --- /dev/null +++ b/src/components/plugins/README.md @@ -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: 插件列表显示 diff --git a/src/components/plugins/index.ts b/src/components/plugins/index.ts index 316f4a208..7c693a9d0 100644 --- a/src/components/plugins/index.ts +++ b/src/components/plugins/index.ts @@ -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"; diff --git a/src/components/tools/ToolsPage.tsx b/src/components/tools/ToolsPage.tsx index 8fe6e3ab9..7919c0461 100644 --- a/src/components/tools/ToolsPage.tsx +++ b/src/components/tools/ToolsPage.tsx @@ -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 ( {icon}
{title} - {status && ( - - {status} - - )} +
+ {status && ( + + {status} + + )} + {source === "plugin" && ( + + 插件 + + )} +
@@ -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([]); + const [loading, setLoading] = useState(true); + const [showInstallDialog, setShowInstallDialog] = useState(false); + const [pendingInstallUrl, setPendingInstallUrl] = useState( + null, + ); + const [installedPluginIds, setInstalledPluginIds] = useState>( + 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 ( + + ); }; return ( @@ -101,46 +333,25 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) { ProxyCast 提供的实用工具集合

- 2 个工具 +
+ {loading && } + {activeToolsCount} 个工具 +
- } - status={interceptorEnabled ? "运行中" : "已停止"} - onClick={handleBrowserInterceptorClick} - /> - - } - onClick={handleMachineIdClick} - /> - - {/* 未来可以添加更多工具 */} - } - disabled - /> - - } - disabled - /> - - } - disabled - /> + {allTools.map((tool) => ( + handleToolClick(tool)} + /> + ))}
@@ -149,8 +360,70 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) { 工具箱是 ProxyCast 的扩展功能模块,提供各种实用工具来增强您的使用体验。 每个工具都经过精心设计,旨在解决特定的使用场景和需求。 + {pluginTools.length > 0 && ( + + 当前已安装 {pluginTools.length} 个插件工具。 + + )}

+ + {/* 推荐插件区域 */} + {uninstalledRecommendedPlugins.length > 0 && ( +
+

推荐插件

+
+ {uninstalledRecommendedPlugins.map((plugin) => { + const Icon = getLucideIcon(plugin.icon); + return ( + + +
+
+ +
+
+ {plugin.name} + + 推荐安装 + +
+
+
+ + + {plugin.description} + + + +
+ ); + })} +
+
+ )} + + {/* 插件安装对话框 */} + { + setShowInstallDialog(false); + setPendingInstallUrl(null); + }} + onSuccess={handleInstallSuccess} + initialUrl={pendingInstallUrl || undefined} + /> ); } diff --git a/src/components/tools/machine-id/MachineIdTool.tsx b/src/components/tools/machine-id/MachineIdTool.tsx index 1998568f8..fddff51a3 100644 --- a/src/components/tools/machine-id/MachineIdTool.tsx +++ b/src/components/tools/machine-id/MachineIdTool.tsx @@ -16,10 +16,11 @@ interface MachineIdToolProps { | "config-management" | "api-server" | "flow-monitor" + | "agent" | "tools" | "browser-interceptor" - | "machine-id" - | "settings", + | "settings" + | `plugin:${string}`, ) => void; } diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 000000000..5725a5617 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 5e20aac82..d4b67178b 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -7,6 +7,7 @@ interface SelectContextType { onValueChange: (value: string) => void; open: boolean; setOpen: (open: boolean) => void; + disabled: boolean; } const SelectContext = createContext(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 = ({ value, defaultValue, onValueChange, + disabled = false, children, }) => { const [internalValue, setInternalValue] = useState(defaultValue || ""); @@ -37,6 +40,7 @@ const Select: React.FC = ({ onValueChange: handleValueChange, open, setOpen, + disabled, }} >
{children}
@@ -56,16 +60,17 @@ const SelectTrigger: React.FC = ({ 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 ( +
+ ); + } + + // 空状态 + if (surfaces.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + // 渲染所有 Surface + return ( +
+ {surfaces.map((surface) => ( + + ))} +
+ ); +}; + +export default PluginUIContainer; diff --git a/src/lib/plugin-ui/PluginUIRenderer.tsx b/src/lib/plugin-ui/PluginUIRenderer.tsx new file mode 100644 index 000000000..45b4001d9 --- /dev/null +++ b/src/lib/plugin-ui/PluginUIRenderer.tsx @@ -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 = ({ + surface, + onAction, + className, +}) => { + /** + * 解析绑定值 + * @param bound - 绑定值定义 + * @param itemData - 列表项数据(用于模板渲染) + */ + const resolveValue = useCallback( + (bound: BoundValue, 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)[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 ( + 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) => ( + + {renderChild(componentId, item)} + + )); + } + + return []; + }, + [surface.dataModel, renderChild], + ); + + // 更新 ref + renderChildrenRef.current = renderChildren; + + // 如果 Surface 未就绪,显示加载状态 + if (!surface.isReady || !surface.rootId) { + return ( +
+
+ 加载中... +
+
+ ); + } + + // 渲染根组件 + return ( +
+ {renderChild(surface.rootId)} +
+ ); +}; + +/** + * 获取 Surface 样式 + */ +function getSurfaceStyles(surface: SurfaceState): React.CSSProperties { + const styles: Record = {}; + + 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: (bound: BoundValue) => T | undefined; + renderChild: (childId: ComponentId) => React.ReactNode; + renderChildren: (children: ChildrenDef) => React.ReactNode[]; + itemData?: unknown; +} + +const ComponentRenderer: React.FC = ({ + 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 ( +
+ 未知组件: {componentType} +
+ ); + } + + // 应用 weight 样式 + const style: React.CSSProperties = {}; + if (componentDef.weight !== undefined) { + style.flexGrow = componentDef.weight; + } + + return ( +
+ +
+ ); +}; + +export default PluginUIRenderer; diff --git a/src/lib/plugin-ui/README.md b/src/lib/plugin-ui/README.md new file mode 100644 index 000000000..75163325c --- /dev/null +++ b/src/lib/plugin-ui/README.md @@ -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 ( +
+

插件详情

+ +
+ ); +} +``` + +## 设计文档 + +详细设计见 `docs/plugin-ui-design.md` + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src/lib/plugin-ui/SurfaceManager.test.ts b/src/lib/plugin-ui/SurfaceManager.test.ts new file mode 100644 index 000000000..771c3300d --- /dev/null +++ b/src/lib/plugin-ui/SurfaceManager.test.ts @@ -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); + } + }); + }); +}); diff --git a/src/lib/plugin-ui/SurfaceManager.ts b/src/lib/plugin-ui/SurfaceManager.ts new file mode 100644 index 000000000..06f87978a --- /dev/null +++ b/src/lib/plugin-ui/SurfaceManager.ts @@ -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 = new Map(); + private dataStores: Map = new Map(); + private listeners: Set<(surfaces: Map) => 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 { + return new Map(this.surfaces); + } + + /** + * 获取 Surface 的数据存储 + */ + getDataStore(surfaceId: SurfaceId): DataStore | undefined { + return this.dataStores.get(surfaceId); + } + + /** + * 订阅 Surface 变化 + */ + subscribe( + listener: (surfaces: Map) => 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(); diff --git a/src/lib/plugin-ui/components/README.md b/src/lib/plugin-ui/components/README.md new file mode 100644 index 000000000..009da9c55 --- /dev/null +++ b/src/lib/plugin-ui/components/README.md @@ -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); +``` + +## 更新提醒 + +任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src/lib/plugin-ui/components/data.tsx b/src/lib/plugin-ui/components/data.tsx new file mode 100644 index 000000000..e4998a928 --- /dev/null +++ b/src/lib/plugin-ui/components/data.tsx @@ -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 = { + start: "items-start", + center: "items-center", + end: "items-end", + stretch: "items-stretch", +}; + +/** + * List 组件 - 列表容器,支持模板渲染 + */ +export const ListRenderer: React.FC = ({ + componentDef, + renderChildren, +}) => { + const props = (componentDef.component as ListComponent).List; + const { + children, + direction = "vertical", + alignment = "stretch", + gap = 8, + } = props; + + const isHorizontal = direction === "horizontal"; + + return ( +
+ {renderChildren(children)} +
+ ); +}; + +/** + * KeyValue 组件 - 键值对展示 + */ +export const KeyValueRenderer: React.FC = ({ + componentDef, + resolveValue, +}) => { + const props = (componentDef.component as KeyValueComponent).KeyValue; + const { items } = props; + + return ( +
+ {items.map((item, index) => { + const keyText = resolveValue(item.key) || ""; + const valueText = resolveValue(item.value) || ""; + + return ( +
+ {keyText} + {valueText} +
+ ); + })} +
+ ); +}; + +/** + * Alert 图标映射 + */ +const alertIconMap: Record> = { + info: Info, + success: CheckCircle, + warning: AlertTriangle, + error: AlertCircle, +}; + +/** + * Alert 样式映射 + */ +const alertStyleMap: Record = { + 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 = ({ + 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 ( + + + {titleText && {titleText}} + {messageText} + + ); +}; diff --git a/src/lib/plugin-ui/components/display.tsx b/src/lib/plugin-ui/components/display.tsx new file mode 100644 index 000000000..87bd77617 --- /dev/null +++ b/src/lib/plugin-ui/components/display.tsx @@ -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 = { + 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 = ({ + componentDef, + resolveValue, +}) => { + const props = (componentDef.component as TextComponent).Text; + const { text, variant = "body" } = props; + + const textContent = resolveValue(text) || ""; + + return {textContent}; +}; + +/** + * 图标映射 + */ +const iconMap: Record = { + 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 = ({ + 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 ( + + ); +}; + +/** + * Badge 变体样式映射 + */ +const badgeVariantMap: Record< + BadgeVariant, + "default" | "secondary" | "destructive" | "outline" +> = { + default: "default", + success: "default", + warning: "secondary", + error: "destructive", + info: "outline", +}; + +const badgeColorMap: Record = { + 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 = ({ + 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 ( + + {textContent} + + ); +}; + +/** + * Progress 组件 + */ +export const ProgressRenderer: React.FC = ({ + 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 ; +}; + +/** + * Spinner 组件 + */ +export const SpinnerRenderer: React.FC = ({ + componentDef, +}) => { + const props = (componentDef.component as SpinnerComponent).Spinner; + const { size = 24 } = props; + + return ( + + ); +}; + +/** + * Empty 组件 + */ +export const EmptyRenderer: React.FC = ({ + 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 ( +
+ {IconComponent && ( + + )} + {titleText && ( +

{titleText}

+ )} + {descText && ( +

{descText}

+ )} +
+ ); +}; + +/** + * Divider 组件 + */ +export const DividerRenderer: React.FC = ({ + componentDef, +}) => { + const props = (componentDef.component as DividerComponent).Divider; + const { axis = "horizontal" } = props; + + if (axis === "vertical") { + return
; + } + + return
; +}; diff --git a/src/lib/plugin-ui/components/index.ts b/src/lib/plugin-ui/components/index.ts new file mode 100644 index 000000000..ad77863ad --- /dev/null +++ b/src/lib/plugin-ui/components/index.ts @@ -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"; diff --git a/src/lib/plugin-ui/components/input.tsx b/src/lib/plugin-ui/components/input.tsx new file mode 100644 index 000000000..517eeaefa --- /dev/null +++ b/src/lib/plugin-ui/components/input.tsx @@ -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 = ({ + 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 = {}; + 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 ( + + {renderChild(child)} + + ); +}; + +/** + * TextField 组件 + */ +export const TextFieldRenderer: React.FC = ({ + 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) => { + 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 ( +
+ + +
+ ); +}; + +/** + * Switch 组件 + */ +export const SwitchRenderer: React.FC = ({ + 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 ( +
+ + +
+ ); +}; + +/** + * Select 组件 + */ +export const SelectRenderer: React.FC = ({ + 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 ( +
+ {labelText && } + + + + + + {options.map((option, index) => { + const optionLabel = resolveValue(option.label) || option.value; + return ( + + {optionLabel} + + ); + })} + + +
+ ); +}; diff --git a/src/lib/plugin-ui/components/layout.tsx b/src/lib/plugin-ui/components/layout.tsx new file mode 100644 index 000000000..b53bce0c1 --- /dev/null +++ b/src/lib/plugin-ui/components/layout.tsx @@ -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 = { + start: "justify-start", + center: "justify-center", + end: "justify-end", + spaceBetween: "justify-between", + spaceAround: "justify-around", + spaceEvenly: "justify-evenly", +}; + +/** + * 对齐方式映射到 CSS align-items + */ +const alignmentMap: Record = { + start: "items-start", + center: "items-center", + end: "items-end", + stretch: "items-stretch", +}; + +/** + * Row 组件 - 水平布局 + */ +export const RowRenderer: React.FC = ({ + componentDef, + renderChildren, +}) => { + const props = (componentDef.component as RowComponent).Row; + const { + children, + distribution = "start", + alignment = "center", + gap = 8, + } = props; + + return ( +
+ {renderChildren(children)} +
+ ); +}; + +/** + * Column 组件 - 垂直布局 + */ +export const ColumnRenderer: React.FC = ({ + componentDef, + renderChildren, +}) => { + const props = (componentDef.component as ColumnComponent).Column; + const { + children, + distribution = "start", + alignment = "stretch", + gap = 8, + } = props; + + return ( +
+ {renderChildren(children)} +
+ ); +}; + +/** + * Card 组件 - 卡片容器 + */ +export const CardRenderer: React.FC = ({ + 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 ( + + {hasHeader && ( + + {titleText && {titleText}} + {descText && {descText}} + + )} + + {renderChild(child)} + + + ); +}; + +/** + * Tabs 组件 - 标签页 + */ +export const TabsRenderer: React.FC = ({ + componentDef, + resolveValue, + renderChild, +}) => { + const props = (componentDef.component as TabsComponent).Tabs; + const { items, defaultTab } = props; + + const defaultValue = defaultTab || items[0]?.id; + + return ( + + + {items.map((item) => { + const tabTitle = resolveValue(item.title) || item.id; + return ( + + {tabTitle} + + ); + })} + + {items.map((item) => ( + + {renderChild(item.child)} + + ))} + + ); +}; diff --git a/src/lib/plugin-ui/components/registry.ts b/src/lib/plugin-ui/components/registry.ts new file mode 100644 index 000000000..2f3b1931d --- /dev/null +++ b/src/lib/plugin-ui/components/registry.ts @@ -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, +}; diff --git a/src/lib/plugin-ui/index.ts b/src/lib/plugin-ui/index.ts new file mode 100644 index 000000000..64e95eee7 --- /dev/null +++ b/src/lib/plugin-ui/index.ts @@ -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(); +} diff --git a/src/lib/plugin-ui/types.ts b/src/lib/plugin-ui/types.ts new file mode 100644 index 000000000..2e9294660 --- /dev/null +++ b/src/lib/plugin-ui/types.ts @@ -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 = + | { 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; + +/** 数字绑定值 */ +export type BoundNumber = BoundValue; + +/** 布尔绑定值 */ +export type BoundBoolean = BoundValue; + +// ============================================================================ +// 子组件定义 +// ============================================================================ + +/** 子组件列表定义 */ +export interface ChildrenDef { + /** 显式列表 - 固定的子组件 ID 列表 */ + explicitList?: ComponentId[]; + /** 模板 - 从数据列表动态生成子组件 */ + template?: { + componentId: ComponentId; + dataBinding: DataPath; + }; +} + +// ============================================================================ +// 操作定义 +// ============================================================================ + +/** 操作上下文项 */ +export interface ActionContextItem { + key: string; + value: BoundValue; +} + +/** 操作定义 */ +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; + 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; + }; +} + +/** 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; + 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; + dataModel: Record; + styles: SurfaceStyles; + isReady: boolean; +} + +// ============================================================================ +// 组件注册表 +// ============================================================================ + +/** 组件渲染器 Props */ +export interface ComponentRendererProps { + componentDef: ComponentDef; + surface: SurfaceState; + onAction: (action: UserAction) => void; + resolveValue: (bound: BoundValue, itemData?: unknown) => T | undefined; + renderChild: (childId: ComponentId, itemData?: unknown) => React.ReactNode; + renderChildren: ( + children: ChildrenDef, + itemData?: unknown, + ) => React.ReactNode[]; +} + +/** 组件渲染器类型 */ +export type ComponentRenderer = React.FC; + +/** 组件注册表条目 */ +export interface ComponentRegistryEntry { + renderer: ComponentRenderer; + schema?: Record; +} diff --git a/src/lib/plugin-ui/usePluginUI.ts b/src/lib/plugin-ui/usePluginUI.ts new file mode 100644 index 000000000..a8a1ac85e --- /dev/null +++ b/src/lib/plugin-ui/usePluginUI.ts @@ -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; + /** 刷新 Surface */ + refresh: () => Promise; +} + +/** + * 插件 UI Hook + * 管理插件的 UI Surface 状态 + */ +export function usePluginUI(options: UsePluginUIOptions): UsePluginUIResult { + const { pluginId, autoInit = true, manager = surfaceManager } = options; + + const [surfaces, setSurfaces] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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("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( + "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; +} { + const { surfaces, handleAction } = usePluginUI({ pluginId }); + + const surface = surfaces.find((s) => s.surfaceId === surfaceId); + + return { surface, handleAction }; +}