diff --git a/docs/content/02.user-guide/13.plugins.md b/docs/content/02.user-guide/13.plugins.md index e99879771..37e0a4d80 100644 --- a/docs/content/02.user-guide/13.plugins.md +++ b/docs/content/02.user-guide/13.plugins.md @@ -1,5 +1,9 @@ # 插件中心 +::alert{type="info"} +📢 本文档已迁移至开放平台。请访问 [开放平台 - 插件中心](/open-platform/plugins) 获取最新内容。 +:: + ProxyCast 支持通过插件扩展功能。插件中心提供插件的安装、管理和配置。 ## 访问插件中心 diff --git a/docs/content/06.development/5.plugin-development.md b/docs/content/06.development/5.plugin-development.md index 0539b4375..befd0a89a 100644 --- a/docs/content/06.development/5.plugin-development.md +++ b/docs/content/06.development/5.plugin-development.md @@ -1,5 +1,9 @@ # 插件开发指南 +::alert{type="info"} +📢 本文档已迁移至开放平台。请访问 [开放平台 - 插件开发](/open-platform/plugin-development) 获取最新内容。 +:: + 本文档描述 ProxyCast 插件系统的规范和开发指南,为插件市场做准备。 ## 插件类型 diff --git a/docs/content/index.md b/docs/content/index.md index b7a0701c2..e885c95fb 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -124,6 +124,19 @@ curl http://127.0.0.1:8999/v1/chat/completions \ +## 🌐 开放平台 + +
+ +

🔌 插件系统

+

通过插件扩展 ProxyCast 功能,支持工具类、Hook 类等多种插件类型

+
+ +

🔗 ProxyCast Connect

+

中转商生态合作方案,一键配置 API Key,提升用户转化率

+
+
+ ## 🤝 社区与支持 - **GitHub Issues**: [报告问题](https://github.com/aiclientproxy/proxycast/issues) diff --git a/docs/prd/proxycast-connect.md b/docs/prd/proxycast-connect.md index 1c97b585f..6bf6a9bc3 100644 --- a/docs/prd/proxycast-connect.md +++ b/docs/prd/proxycast-connect.md @@ -155,7 +155,7 @@ ProxyCast 是开源软件,中转商通过 **GitHub PR** 方式注册,无需 中转商注册信息统一管理在独立仓库: ``` -https://github.com/AiClientProxy/transit-service-provider +https://github.com/aiclientproxy/connect ``` #### 2.3.2 注册流程 @@ -167,7 +167,7 @@ https://github.com/AiClientProxy/transit-service-provider │ │ │ Step 1: Fork 仓库 │ │ ───────────────── │ -│ Fork https://github.com/AiClientProxy/transit-service-provider │ +│ Fork https://github.com/aiclientproxy/connect │ │ │ │ │ ▼ │ │ Step 2: 创建配置文件 │ @@ -302,7 +302,7 @@ transit-service-provider/ ProxyCast 客户端通过以下方式获取中转商列表: ``` -https://raw.githubusercontent.com/AiClientProxy/transit-service-provider/main/dist/registry.json +https://raw.githubusercontent.com/aiclientproxy/connect/main/dist/registry.json ``` 客户端定期(每 24 小时)同步更新,也可手动刷新 @@ -596,7 +596,7 @@ fn default_true() -> bool { true } impl RelayRegistry { /// 注册表 URL(从 GitHub 加载) const REGISTRY_URL: &'static str = - "https://raw.githubusercontent.com/AiClientProxy/transit-service-provider/main/dist/registry.json"; + "https://raw.githubusercontent.com/aiclientproxy/connect/main/dist/registry.json"; /// 从 GitHub 加载中转商列表 pub async fn load_from_github(&mut self) -> Result<()> { @@ -642,7 +642,7 @@ impl RelayRegistry { │ │ │ Step 1: Fork 仓库 │ │ ───────────────── │ -│ Fork https://github.com/AiClientProxy/transit-service-provider │ +│ Fork https://github.com/aiclientproxy/connect │ │ │ │ │ ▼ │ │ Step 2: 创建配置文件 │ @@ -798,7 +798,209 @@ function downloadConfig(apiKey) { └─────────────────────────────────────────────────────────────────────────┘ ``` -### 6.3 中转商审核 +### 5.3 统计回调(Webhook) + +为了让中转商追踪推广效果,ProxyCast 提供统计回调机制。 + +#### 5.3.1 回调流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 统计回调流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 用户点击一键配置 │ +│ │ │ +│ ▼ │ +│ ProxyCast 打开,显示确认弹窗 │ +│ │ │ +│ ├─── 用户取消 ─────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 发送回调: status=cancelled │ │ +│ │ │ │ +│ └─── 用户确认 ─────────────────────────────────────────────────┐ │ +│ │ │ +│ Key 添加成功 │ │ +│ │ │ │ +│ ▼ │ │ +│ 发送回调: status=success │ │ +│ │ │ │ +│ ▼ │ │ +│ 中转商收到回调,更新统计 │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 5.3.2 回调配置 + +中转商在 `providers/{id}.json` 中配置回调地址: + +```json +{ + "id": "myrelay", + "name": "我的中转站", + // ... 其他字段 ... + + "webhook": { + "callback_url": "https://api.myrelay.com/proxycast/callback", + "secret": "whsec_xxxxxxxxxxxxxxxx" + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `webhook.callback_url` | ❌ | 回调地址(HTTPS) | +| `webhook.secret` | ❌ | 签名密钥,用于验证请求来源 | + +#### 5.3.3 回调请求格式 + +ProxyCast 向中转商发送 POST 请求: + +```http +POST https://api.myrelay.com/proxycast/callback +Content-Type: application/json +X-ProxyCast-Signature: sha256=xxxxxxxxxxxxxxxx +X-ProxyCast-Timestamp: 1704067200 + +{ + "event": "connect", + "status": "success", + "relay_id": "myrelay", + "ref": "promo2024", + "key_prefix": "sk-xxxx", + "timestamp": "2026-01-05T12:00:00Z", + "client": { + "version": "1.2.0", + "platform": "macos" + } +} +``` + +#### 5.3.4 回调字段说明 + +| 字段 | 说明 | +|------|------| +| `event` | 事件类型:`connect` | +| `status` | 状态:`success`(成功)、`cancelled`(用户取消)、`error`(失败) | +| `relay_id` | 中转商 ID | +| `ref` | 推广码(如果有) | +| `key_prefix` | Key 前缀(脱敏,仅前 7 位) | +| `timestamp` | 事件时间 | +| `client.version` | ProxyCast 版本 | +| `client.platform` | 平台:`macos`、`windows`、`linux` | +| `error_code` | 错误码(仅 status=error 时) | +| `error_message` | 错误信息(仅 status=error 时) | + +#### 5.3.5 签名验证 + +中转商应验证请求签名,防止伪造: + +```javascript +const crypto = require('crypto'); + +function verifySignature(payload, signature, secret, timestamp) { + // 检查时间戳,防止重放攻击(5分钟内有效) + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - parseInt(timestamp)) > 300) { + return false; + } + + // 计算签名 + const signedPayload = `${timestamp}.${JSON.stringify(payload)}`; + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(signedPayload) + .digest('hex'); + + return `sha256=${expectedSignature}` === signature; +} + +// Express 示例 +app.post('/proxycast/callback', (req, res) => { + const signature = req.headers['x-proxycast-signature']; + const timestamp = req.headers['x-proxycast-timestamp']; + + if (!verifySignature(req.body, signature, WEBHOOK_SECRET, timestamp)) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + // 处理回调 + const { event, status, ref, key_prefix } = req.body; + + if (status === 'success') { + // 记录成功安装 + await db.stats.increment({ + relay_id: req.body.relay_id, + ref: ref, + date: new Date().toISOString().split('T')[0] + }); + } + + res.json({ received: true }); +}); +``` + +#### 5.3.6 回调重试机制 + +| 重试次数 | 间隔 | 说明 | +|----------|------|------| +| 第 1 次 | 立即 | 首次发送 | +| 第 2 次 | 1 分钟 | 首次失败后 | +| 第 3 次 | 5 分钟 | 第二次失败后 | +| 第 4 次 | 30 分钟 | 第三次失败后 | +| 放弃 | - | 超过 4 次不再重试 | + +成功响应:HTTP 2xx 状态码 + +#### 5.3.7 统计数据示例 + +中转商可以基于回调数据构建统计面板: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ProxyCast Connect 统计 2026-01-05 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 今日概览 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 128 │ │ 115 │ │ 13 │ │ 89.8% │ │ +│ │ 点击次数 │ │ 成功配置 │ │ 用户取消 │ │ 转化率 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ 推广码效果 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 推广码 点击 成功 取消 转化率 │ │ +│ ├─────────────────────────────────────────────────────────────────┤ │ +│ │ promo2024 56 52 4 92.9% │ │ +│ │ twitter 38 33 5 86.8% │ │ +│ │ (无推广码) 34 30 4 88.2% │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 平台分布 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ macOS ████████████████████████████████████ 72% │ │ +│ │ Windows ████████████████ 23% │ │ +│ │ Linux ████ 5% │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 5.3.8 隐私保护 + +回调数据遵循最小化原则: + +| 数据 | 是否发送 | 说明 | +|------|----------|------| +| 完整 API Key | ❌ | 仅发送前 7 位前缀 | +| 用户 ID | ❌ | 不发送任何用户标识 | +| 设备 ID | ❌ | 不发送设备标识 | +| IP 地址 | ❌ | 不发送用户 IP | +| 推广码 | ✅ | 用于统计推广效果 | +| 平台信息 | ✅ | 仅操作系统类型 | + +### 5.4 中转商审核 | 审核项 | 说明 | |--------|------| @@ -912,5 +1114,5 @@ function downloadConfig(apiKey) { | 仓库 | 说明 | |------|------| -| [proxycast](https://github.com/AiClientProxy/proxycast) | ProxyCast 主项目 | -| [transit-service-provider](https://github.com/AiClientProxy/transit-service-provider) | 中转商注册仓库 | +| [proxycast](https://github.com/aiclientproxy/proxycast) | ProxyCast 主项目 | +| [transit-service-provider](https://github.com/aiclientproxy/connect) | 中转商注册仓库 | diff --git a/package-lock.json b/package-lock.json index 3ef1558a5..6b4924007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxycast", - "version": "0.25.0", + "version": "0.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.25.0", + "version": "0.29.0", "dependencies": { "@fabianlars/tauri-plugin-oauth": "^2", "@radix-ui/react-collapsible": "^1.1.12", @@ -23,6 +23,7 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-deep-link": "^2.4.5", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-shell": "^2.0.0", "@types/styled-components": "^5.1.36", @@ -3347,6 +3348,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.5.tgz", + "integrity": "sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.4.2.tgz", diff --git a/package.json b/package.json index c76a7592c..a8dc0405f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-deep-link": "^2.4.5", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-shell": "^2.0.0", "@types/styled-components": "^5.1.36", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5195ea5e0..3618e216e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3692,6 +3692,7 @@ dependencies = [ "fs2", "futures", "glob", + "hex", "indexmap 2.12.1", "md5", "notify", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 03a8d7330..8928e629f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -65,6 +65,7 @@ once_cell = "1" tokio-util = "0.7" arboard = "3" glob = "0.3.3" +hex = "0.4.3" # Platform specific dependencies for browser interceptor diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 897aaf6f7..14f9bbfd5 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -11,6 +11,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。 - `commands/` - Tauri 命令处理(前端调用入口) - `config/` - 配置管理(导入/导出/热重载) +- `connect/` - ProxyCast Connect 模块(中转商生态合作) - `converter/` - 协议转换(OpenAI ↔ CW/Claude/Antigravity) - `credential/` - 凭证池管理(负载均衡、健康检查) - `database/` - 数据库层(SQLite + DAO) diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index b8a691338..0667b76a8 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -7,6 +7,7 @@ use tokio::sync::RwLock; use crate::agent::NativeAgentState; use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; +use crate::commands::connect_cmd::ConnectStateWrapper; use crate::commands::flow_monitor_cmd::{ BatchOperationsState, BookmarkManagerState, EnhancedStatsServiceState, FlowInterceptorState, FlowMonitorState, FlowQueryServiceState, FlowReplayerState, QuickFilterManagerState, @@ -130,6 +131,7 @@ pub struct AppStates { pub native_agent: NativeAgentState, pub oauth_plugin_manager: crate::commands::oauth_plugin_cmd::OAuthPluginManagerState, pub orchestrator: OrchestratorState, + pub connect_state: ConnectStateWrapper, // 用于 setup hook 的共享实例 pub shared_stats: Arc>, pub shared_tokens: Arc>, @@ -202,6 +204,9 @@ pub fn init_states(config: &Config) -> Result { crate::commands::oauth_plugin_cmd::OAuthPluginManagerState::with_defaults(); let orchestrator_state = OrchestratorState::new(); + // 初始化 Connect 状态(延迟初始化,在 setup hook 中完成) + let connect_state = ConnectStateWrapper(Arc::new(RwLock::new(None))); + // 初始化默认技能仓库 { let conn = db.lock().expect("Failed to lock database"); @@ -236,6 +241,7 @@ pub fn init_states(config: &Config) -> Result { native_agent: native_agent_state, oauth_plugin_manager: oauth_plugin_manager_state, orchestrator: orchestrator_state, + connect_state, shared_stats, shared_tokens, shared_logger, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 38873ca55..44a8242c6 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -3,7 +3,7 @@ //! 包含 Tauri 应用的主入口函数和命令注册。 use std::sync::Arc; -use tauri::Manager; +use tauri::{Emitter, Listener, Manager}; use crate::commands; use crate::tray::{TrayIconStatus, TrayManager, TrayStateSnapshot}; @@ -70,6 +70,7 @@ pub fn run() { native_agent: native_agent_state, oauth_plugin_manager: oauth_plugin_manager_state, orchestrator: orchestrator_state, + connect_state: connect_state, shared_stats, shared_tokens, shared_logger, @@ -95,7 +96,16 @@ pub fn run() { .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec!["--minimized"]), - )) + )); + + // 在 macOS 上注册 Deep Link 插件 + // _Requirements: 1.4_ + #[cfg(target_os = "macos")] + { + builder = builder.plugin(tauri_plugin_deep_link::init()); + } + + builder = builder // 单实例插件:当第二个实例启动时,将 URL 传递给第一个实例 .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args); @@ -134,6 +144,7 @@ pub fn run() { .manage(native_agent_state) .manage(oauth_plugin_manager_state) .manage(orchestrator_state) + .manage(connect_state) .on_window_event(move |window, event| { // 处理窗口关闭事件 if let tauri::WindowEvent::CloseRequested { api, .. } = event { @@ -178,6 +189,128 @@ pub fn run() { app.manage(tray_state); } } + + // 初始化 Connect 状态 + // _Requirements: 1.4, 2.1_ + { + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + // 获取应用数据目录 + let app_data_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("proxycast"); + + // 初始化 Connect 状态 + match crate::commands::connect_cmd::init_connect_state(app_data_dir).await { + Ok(connect_state_inner) => { + tracing::info!("[启动] Connect 模块初始化成功"); + // 更新状态 + if let Some(state) = app_handle + .try_state::() + { + let mut guard = state.0.write().await; + *guard = Some(connect_state_inner); + } + } + Err(e) => { + tracing::error!("[启动] Connect 模块初始化失败: {:?}", e); + } + } + }); + } + + // 注册 Deep Link 事件处理器(仅 macOS) + // _Requirements: 1.4_ + #[cfg(target_os = "macos")] + { + let app_handle = app.handle().clone(); + app.listen("deep-link://new-url", move |event| { + let urls = event.payload().to_string(); + tracing::info!("[Deep Link] 收到 URL: {}", urls); + // 解析 URL 并处理 + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + // 尝试解析为 JSON 数组(Tauri deep-link 插件返回的格式) + if let Ok(url_list) = serde_json::from_str::>(&urls) { + for url in url_list { + if url.starts_with("proxycast://connect") { + // 调用 handle_deep_link 命令 + if let Some(state) = app_handle_clone + .try_state::() + { + match crate::connect::parse_deep_link(&url) { + Ok(payload) => { + // 查询中转商信息 + let (relay_info, is_verified) = { + let state_guard = state.0.read().await; + if let Some(connect_state) = state_guard.as_ref() { + let info = connect_state.registry.get(&payload.relay); + let verified = info.is_some(); + (info, verified) + } else { + (None, false) + } + }; + + let result = crate::commands::connect_cmd::DeepLinkResult { + payload, + relay_info, + is_verified, + }; + + // 发送事件到前端 + if let Err(e) = app_handle_clone.emit("deep-link-connect", &result) { + tracing::error!("[Deep Link] 发送事件失败: {}", e); + } + } + Err(e) => { + tracing::error!("[Deep Link] 解析 URL 失败: {:?}", e); + // 发送错误事件到前端 + let _ = app_handle_clone.emit("deep-link-error", &format!("{:?}", e)); + } + } + } + } + } + } else if urls.starts_with("proxycast://connect") { + // 直接处理单个 URL + if let Some(state) = app_handle_clone + .try_state::() + { + match crate::connect::parse_deep_link(&urls) { + Ok(payload) => { + let (relay_info, is_verified) = { + let state_guard = state.0.read().await; + if let Some(connect_state) = state_guard.as_ref() { + let info = connect_state.registry.get(&payload.relay); + let verified = info.is_some(); + (info, verified) + } else { + (None, false) + } + }; + + let result = crate::commands::connect_cmd::DeepLinkResult { + payload, + relay_info, + is_verified, + }; + + if let Err(e) = app_handle_clone.emit("deep-link-connect", &result) { + tracing::error!("[Deep Link] 发送事件失败: {}", e); + } + } + Err(e) => { + tracing::error!("[Deep Link] 解析 URL 失败: {:?}", e); + let _ = app_handle_clone.emit("deep-link-error", &format!("{:?}", e)); + } + } + } + } + }); + }); + } + // 自动启动服务器 let state = state_clone.clone(); let logs = logs_clone.clone(); @@ -843,6 +976,14 @@ pub fn run() { commands::orchestrator_cmd::list_strategies, commands::orchestrator_cmd::list_service_tiers, commands::orchestrator_cmd::list_task_hints, + // Connect commands + // _Requirements: 1.4, 2.3, 4.1, 5.3_ + commands::connect_cmd::handle_deep_link, + commands::connect_cmd::get_relay_info, + commands::connect_cmd::save_relay_api_key, + commands::connect_cmd::refresh_relay_registry, + commands::connect_cmd::list_relay_providers, + commands::connect_cmd::send_connect_callback, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/commands/connect_cmd.rs b/src-tauri/src/commands/connect_cmd.rs new file mode 100644 index 000000000..cc66eb15c --- /dev/null +++ b/src-tauri/src/commands/connect_cmd.rs @@ -0,0 +1,423 @@ +//! ProxyCast Connect 命令模块 +//! +//! 提供 Deep Link 协议处理和中转商 API Key 管理的 Tauri 命令。 +//! +//! ## 功能 +//! +//! - Deep Link 协议处理和事件发送 +//! - 中转商注册表查询和刷新 +//! - API Key 集成到 API Key Provider 系统 +//! - 统计回调(Webhook) +//! +//! ## Tauri 命令 +//! +//! - `get_relay_info` - 查询中转商信息 +//! - `save_relay_api_key` - 保存 API Key(添加到 API Key Provider 系统) +//! - `refresh_relay_registry` - 刷新注册表 +//! - `handle_deep_link` - 处理 Deep Link URL +//! - `send_connect_callback` - 发送统计回调 +//! +//! _Requirements: 1.4, 2.3, 4.1, 5.3_ + +use crate::connect::{ + parse_deep_link, send_cancelled_callback, send_error_callback, send_success_callback, + ConnectPayload, DeepLinkError, RelayInfo, RelayRegistry, +}; +use crate::database::dao::api_key_provider::ApiProviderType; +use crate::database::DbConnection; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::RwLock; + +use super::api_key_provider_cmd::ApiKeyProviderServiceState; + +/// Connect 模块状态 +/// +/// 管理 RelayRegistry 的共享状态 +pub struct ConnectState { + /// 中转商注册表 + pub registry: Arc, +} + +/// Connect 状态包装器(用于 Tauri 状态管理) +pub struct ConnectStateWrapper(pub Arc>>); + +/// Deep Link 处理结果 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DeepLinkResult { + /// 解析后的 payload + pub payload: ConnectPayload, + /// 中转商信息(如果在注册表中找到) + pub relay_info: Option, + /// 是否为已验证的中转商 + pub is_verified: bool, +} + +/// 命令错误类型 +#[derive(Debug, Serialize, Deserialize)] +pub struct ConnectError { + pub code: String, + pub message: String, +} + +impl From for ConnectError { + fn from(err: DeepLinkError) -> Self { + let (code, message) = match &err { + DeepLinkError::InvalidUrl(msg) => ("INVALID_URL".to_string(), msg.clone()), + DeepLinkError::MissingRelay => { + ("MISSING_RELAY".to_string(), "缺少必填参数: relay".to_string()) + } + DeepLinkError::MissingKey => { + ("MISSING_KEY".to_string(), "缺少必填参数: key".to_string()) + } + }; + ConnectError { code, message } + } +} + +impl From for ConnectError { + fn from(err: crate::connect::RegistryError) -> Self { + ConnectError { + code: "REGISTRY_ERROR".to_string(), + message: err.to_string(), + } + } +} + +/// 初始化 Connect 状态 +/// +/// 在应用启动时调用,初始化 RelayRegistry +pub async fn init_connect_state(app_data_dir: PathBuf) -> Result { + // 初始化 Registry + let cache_path = app_data_dir.join("connect").join("registry.json"); + let registry = Arc::new(RelayRegistry::new(cache_path)); + + // 尝试从缓存加载,如果失败则从远程加载 + if let Err(_) = registry.load_from_cache() { + tracing::info!("[Connect] 缓存不存在,尝试从远程加载注册表"); + if let Err(e) = registry.load_from_remote().await { + tracing::warn!("[Connect] 从远程加载注册表失败: {}", e); + // 不返回错误,允许应用继续运行 + } + } + + Ok(ConnectState { registry }) +} + +/// 处理 Deep Link URL +/// +/// 解析 Deep Link URL,查询中转商信息,并发送事件到前端 +/// +/// _Requirements: 1.4_ +#[tauri::command] +pub async fn handle_deep_link( + app: AppHandle, + state: State<'_, ConnectStateWrapper>, + url: String, +) -> Result { + tracing::info!("[Connect] 处理 Deep Link: {}", url); + + // 解析 Deep Link + let payload = parse_deep_link(&url)?; + + // 查询中转商信息 + let (relay_info, is_verified) = { + let state_guard = state.0.read().await; + if let Some(connect_state) = state_guard.as_ref() { + let info = connect_state.registry.get(&payload.relay); + let verified = info.is_some(); + (info, verified) + } else { + (None, false) + } + }; + + let result = DeepLinkResult { + payload: payload.clone(), + relay_info: relay_info.clone(), + is_verified, + }; + + // 发送事件到前端 + // _Requirements: 1.4_ + if let Err(e) = app.emit("deep-link-connect", &result) { + tracing::error!("[Connect] 发送 deep-link-connect 事件失败: {}", e); + } + + Ok(result) +} + +/// 查询中转商信息 +/// +/// _Requirements: 2.3_ +#[tauri::command] +pub async fn get_relay_info( + state: State<'_, ConnectStateWrapper>, + relay_id: String, +) -> Result, ConnectError> { + let state_guard = state.0.read().await; + if let Some(connect_state) = state_guard.as_ref() { + Ok(connect_state.registry.get(&relay_id)) + } else { + Err(ConnectError { + code: "NOT_INITIALIZED".to_string(), + message: "Connect 模块未初始化".to_string(), + }) + } +} + +/// 保存 API Key 的返回结果 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SaveApiKeyResult { + /// Provider ID + pub provider_id: String, + /// API Key ID + pub key_id: String, + /// Provider 名称 + pub provider_name: String, + /// 是否为新创建的 Provider + pub is_new_provider: bool, +} + +/// 保存 API Key +/// +/// 将 API Key 添加到 API Key Provider 系统。 +/// 如果中转商对应的 Provider 不存在,会自动创建一个自定义 Provider。 +/// +/// _Requirements: 4.1_ +#[tauri::command] +pub async fn save_relay_api_key( + state: State<'_, ConnectStateWrapper>, + db: State<'_, DbConnection>, + api_key_service: State<'_, ApiKeyProviderServiceState>, + relay_id: String, + api_key: String, + name: Option, +) -> Result { + let state_guard = state.0.read().await; + let connect_state = state_guard.as_ref().ok_or_else(|| ConnectError { + code: "NOT_INITIALIZED".to_string(), + message: "Connect 模块未初始化".to_string(), + })?; + + // 获取中转商信息以确定协议类型和 base_url + let relay_info = connect_state.registry.get(&relay_id).ok_or_else(|| ConnectError { + code: "RELAY_NOT_FOUND".to_string(), + message: format!("中转商 {} 不在注册表中", relay_id), + })?; + + let protocol = relay_info.api.protocol.to_lowercase(); + let base_url = relay_info.api.base_url.clone(); + + // 确定 API Provider 类型 + let provider_type = if protocol == "claude" || protocol == "anthropic" { + ApiProviderType::Anthropic + } else { + ApiProviderType::Openai + }; + + // 生成 Provider ID(使用 connect- 前缀 + 中转商 ID) + let provider_id = format!("connect-{}", relay_id); + + // 检查 Provider 是否已存在 + let existing_provider = api_key_service + .0 + .get_provider(&db, &provider_id) + .map_err(|e| ConnectError { + code: "GET_PROVIDER_FAILED".to_string(), + message: format!("查询 Provider 失败: {}", e), + })?; + + let (final_provider_id, is_new_provider) = if existing_provider.is_some() { + // Provider 已存在,直接使用 + (provider_id, false) + } else { + // 创建新的自定义 Provider + let provider_name = name.clone().unwrap_or_else(|| format!("[Connect] {}", relay_info.name)); + let new_provider = api_key_service + .0 + .add_custom_provider( + &db, + provider_name, + provider_type, + base_url, + None, // api_version + None, // project + None, // location + None, // region + ) + .map_err(|e| ConnectError { + code: "CREATE_PROVIDER_FAILED".to_string(), + message: format!("创建 Provider 失败: {}", e), + })?; + + tracing::info!( + "[Connect] 创建新 Provider: id={}, name={}", + new_provider.id, + new_provider.name + ); + + (new_provider.id, true) + }; + + // 添加 API Key 到 Provider + let key_alias = name.or_else(|| Some(format!("[Connect] {}", relay_info.name))); + let api_key_entry = api_key_service + .0 + .add_api_key(&db, &final_provider_id, &api_key, key_alias.clone()) + .map_err(|e| ConnectError { + code: "ADD_API_KEY_FAILED".to_string(), + message: format!("添加 API Key 失败: {}", e), + })?; + + tracing::info!( + "[Connect] 已添加 API Key: relay={}, provider_id={}, key_id={}", + relay_id, + final_provider_id, + api_key_entry.id + ); + + Ok(SaveApiKeyResult { + provider_id: final_provider_id, + key_id: api_key_entry.id, + provider_name: key_alias.unwrap_or_else(|| relay_info.name.clone()), + is_new_provider, + }) +} + +/// 刷新中转商注册表 +/// +/// _Requirements: 2.5_ +#[tauri::command] +pub async fn refresh_relay_registry( + state: State<'_, ConnectStateWrapper>, +) -> Result { + let state_guard = state.0.read().await; + if let Some(connect_state) = state_guard.as_ref() { + connect_state.registry.load_from_remote().await?; + let count = connect_state.registry.len(); + tracing::info!("[Connect] 注册表已刷新,共 {} 个中转商", count); + Ok(count) + } else { + Err(ConnectError { + code: "NOT_INITIALIZED".to_string(), + message: "Connect 模块未初始化".to_string(), + }) + } +} + +/// 获取所有中转商列表 +#[tauri::command] +pub async fn list_relay_providers( + state: State<'_, ConnectStateWrapper>, +) -> Result, ConnectError> { + let state_guard = state.0.read().await; + if let Some(connect_state) = state_guard.as_ref() { + Ok(connect_state.registry.list()) + } else { + Err(ConnectError { + code: "NOT_INITIALIZED".to_string(), + message: "Connect 模块未初始化".to_string(), + }) + } +} + +/// 回调状态类型 +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CallbackStatusType { + /// 配置成功 + Success, + /// 用户取消 + Cancelled, + /// 配置失败 + Error, +} + +/// 发送统计回调 +/// +/// 在用户确认/取消配置后调用,向中转商发送统计回调 +/// +/// _Requirements: 5.3_ +#[tauri::command] +pub async fn send_connect_callback( + state: State<'_, ConnectStateWrapper>, + relay_id: String, + api_key: String, + status: CallbackStatusType, + ref_code: Option, + error_code: Option, + error_message: Option, +) -> Result { + tracing::info!( + "[Connect] 发送统计回调: relay={}, status={:?}", + relay_id, + status + ); + + // 获取中转商信息 + let relay_info = { + let state_guard = state.0.read().await; + if let Some(connect_state) = state_guard.as_ref() { + connect_state.registry.get(&relay_id) + } else { + return Err(ConnectError { + code: "NOT_INITIALIZED".to_string(), + message: "Connect 模块未初始化".to_string(), + }); + } + }; + + // 检查是否配置了 webhook + let webhook = match relay_info { + Some(info) => info.webhook, + None => { + tracing::debug!("[Connect] 中转商 {} 不在注册表中,跳过回调", relay_id); + return Ok(false); + } + }; + + let webhook = match webhook { + Some(w) => w, + None => { + tracing::debug!("[Connect] 中转商 {} 未配置 webhook,跳过回调", relay_id); + return Ok(false); + } + }; + + let callback_url = match webhook.callback_url { + Some(url) => url, + None => { + tracing::debug!( + "[Connect] 中转商 {} webhook 配置不完整,跳过回调", + relay_id + ); + return Ok(false); + } + }; + + // 发送回调(异步,不阻塞) + match status { + CallbackStatusType::Success => { + send_success_callback(&callback_url, &relay_id, &api_key, ref_code); + } + CallbackStatusType::Cancelled => { + send_cancelled_callback(&callback_url, &relay_id, &api_key, ref_code); + } + CallbackStatusType::Error => { + send_error_callback( + &callback_url, + &relay_id, + &api_key, + ref_code, + error_code.as_deref().unwrap_or("UNKNOWN"), + error_message.as_deref().unwrap_or("未知错误"), + ); + } + } + + tracing::info!("[Connect] 统计回调已触发: relay={}", relay_id); + Ok(true) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6529bde21..5a634cda3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod api_key_provider_cmd; pub mod auto_fix_cmd; pub mod browser_interceptor_cmd; pub mod config_cmd; +pub mod connect_cmd; pub mod flow_monitor_cmd; pub mod injection_cmd; pub mod kiro_local; diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 0ce76db98..2d5279223 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -450,6 +450,31 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // ============================================================================ + // ProxyCast Connect 相关表 + // ============================================================================ + + // Relay API Keys 表 + // 存储中转商 API Key + // _Requirements: 4.1, 4.3_ + conn.execute( + "CREATE TABLE IF NOT EXISTS relay_api_keys ( + id TEXT PRIMARY KEY, + relay_id TEXT NOT NULL, + api_key TEXT NOT NULL, + name TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + // 创建 relay_api_keys 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_relay_api_keys_relay_id ON relay_api_keys(relay_id)", + [], + )?; + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c075efce9..de1d1cad1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub mod agent; pub mod app; pub mod backends; pub mod browser_interceptor; +pub mod connect; pub mod credential; pub mod database; pub mod flow_monitor; diff --git a/src/App.tsx b/src/App.tsx index 18e214907..a1435ad56 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ * 支持静态页面和动态插件页面路由 * 包含启动画面和全局图标侧边栏 * - * _需求: 2.2, 3.2_ + * _需求: 2.2, 3.2, 5.2_ */ import { useState, useEffect, useCallback } from "react"; @@ -22,6 +22,10 @@ import { PluginsPage } from "./components/plugins/PluginsPage"; import { Toaster } from "./components/ui/sonner"; import { flowEventManager } from "./lib/flowEventManager"; import { OnboardingWizard, useOnboardingState } from "./components/onboarding"; +import { ConnectConfirmDialog } from "./components/connect"; +import { showRegistryLoadError } from "./lib/utils/connectError"; +import { useDeepLink } from "./hooks/useDeepLink"; +import { useRelayRegistry } from "./hooks/useRelayRegistry"; /** * 页面类型定义 @@ -67,11 +71,41 @@ function App() { const [currentPage, setCurrentPage] = useState("agent"); const { needsOnboarding, completeOnboarding } = useOnboardingState(); + // Deep Link 处理 Hook + // _Requirements: 5.2_ + const { + connectPayload, + relayInfo, + isVerified, + isDialogOpen, + isSaving, + error, + handleConfirm, + handleCancel, + } = useDeepLink(); + + // Relay Registry 管理 Hook + // _Requirements: 2.1, 7.2, 7.3_ + const { + error: registryError, + refresh: _refreshRegistry, // 保留以供后续错误处理 UI 使用 + } = useRelayRegistry(); + // 在应用启动时初始化 Flow 事件订阅 useEffect(() => { flowEventManager.subscribe(); }, []); + // 处理 Registry 加载失败 + // _Requirements: 7.2, 7.3_ + useEffect(() => { + if (registryError) { + console.warn("[App] Registry 加载失败:", registryError); + // 显示 toast 通知用户 + showRegistryLoadError(registryError.message); + } + }, [registryError]); + // 页面切换时重置滚动位置 useEffect(() => { const mainElement = document.querySelector("main"); @@ -181,6 +215,20 @@ function App() { {renderPage()} + {/* ProxyCast Connect 确认弹窗 */} + {/* _Requirements: 5.2_ */} + ); } diff --git a/src/components/README.md b/src/components/README.md index fb2811f62..9bf69860d 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -13,6 +13,7 @@ React 组件层,包含 UI 组件和业务组件。 - `api-server/` - API 服务器配置组件 - `clients/` - 客户端管理组件 - `config/` - 配置管理组件 +- `connect/` - ProxyCast Connect 组件(中转商 API Key 添加) - `extensions/` - 扩展功能组件 - `flow-monitor/` - LLM 流量监控组件 - `mcp/` - MCP 服务器管理组件 diff --git a/src/hooks/README.md b/src/hooks/README.md index 87bafc5a1..0fe14ef73 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -11,6 +11,8 @@ React 自定义 Hooks,封装业务逻辑和状态管理。 - `index.ts` - Hooks 导出入口 - `useApiKeyProvider.ts` - API Key Provider 管理 Hook(Requirements 9.1) +- `useConnectCallback.ts` - Connect 统计回调 Hook(Requirements 5.3) +- `useDeepLink.ts` - Deep Link 事件处理 Hook(Requirements 5.1, 5.2, 5.3, 5.4) - `useErrorHandler.ts` - 错误处理 Hook - `useFileMonitoring.ts` - 文件监控 Hook - `useFlowActions.ts` - 流量操作 Hook @@ -21,6 +23,7 @@ React 自定义 Hooks,封装业务逻辑和状态管理。 - `usePrompts.ts` - Prompt 管理 Hook - `useProviderPool.ts` - Provider 池管理 Hook - `useProviderState.ts` - Provider 状态 Hook +- `useRelayRegistry.ts` - Relay Registry 管理 Hook(Requirements 2.1, 7.2, 7.3) - `useSkills.ts` - 技能管理 Hook - `useSwitch.ts` - 开关状态 Hook - `useTauri.ts` - Tauri 通用 Hook diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 405822499..1727f7e68 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,16 @@ export { useFlowEvents } from "./useFlowEvents"; export { useOAuthPlugins, useSingleOAuthPlugin } from "./useOAuthPlugins"; +export { useDeepLink } from "./useDeepLink"; +export type { + ConnectPayload, + RelayInfo, + RelayBranding, + RelayLinks, + RelayApi, + RelayContact, + RelayFeatures, + DeepLinkResult, + SaveApiKeyResult, + ConnectError, + UseDeepLinkReturn, +} from "./useDeepLink"; diff --git a/src/hooks/useDeepLink.ts b/src/hooks/useDeepLink.ts new file mode 100644 index 000000000..2157f215d --- /dev/null +++ b/src/hooks/useDeepLink.ts @@ -0,0 +1,470 @@ +/** + * @file Deep Link 事件处理 Hook + * @description 监听 Deep Link URL,管理 Connect 弹窗状态 + * @module hooks/useDeepLink + * + * _Requirements: 5.1, 5.2, 5.3, 5.4, 7.1, 7.4_ + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { listen, UnlistenFn } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; +import { + showDeepLinkError, + showApiKeySaveError, +} from "@/lib/utils/connectError"; +import { useConnectCallback } from "./useConnectCallback"; + +/** + * Deep Link 解析后的 payload + */ +export interface ConnectPayload { + /** 中转商 ID(必填) */ + relay: string; + /** API Key(必填) */ + key: string; + /** Key 名称(可选) */ + name?: string; + /** 推广码(可选) */ + ref_code?: string; +} + +/** + * 中转商品牌信息 + */ +export interface RelayBranding { + /** Logo URL */ + logo: string; + /** 主题色 */ + color: string; +} + +/** + * 中转商链接 + */ +export interface RelayLinks { + /** 主页 */ + homepage: string; + /** 注册链接 */ + register?: string; + /** 充值链接 */ + recharge?: string; + /** 文档链接 */ + docs?: string; + /** 状态页链接 */ + status?: string; +} + +/** + * 中转商 API 配置 + */ +export interface RelayApi { + /** API 基础 URL */ + base_url: string; + /** 协议类型 */ + protocol: string; + /** 认证头名称 */ + auth_header: string; + /** 认证前缀 */ + auth_prefix: string; +} + +/** + * 中转商联系方式 + */ +export interface RelayContact { + email?: string; + discord?: string; + telegram?: string; + twitter?: string; +} + +/** + * 中转商功能特性 + */ +export interface RelayFeatures { + /** 支持的模型列表 */ + models: string[]; + /** 是否支持流式响应 */ + streaming: boolean; + /** 是否支持函数调用 */ + function_calling: boolean; + /** 是否支持视觉模型 */ + vision: boolean; +} + +/** + * 中转商信息 + */ +export interface RelayInfo { + /** 中转商唯一 ID */ + id: string; + /** 中转商名称 */ + name: string; + /** 中转商描述 */ + description: string; + /** 品牌信息 */ + branding: RelayBranding; + /** 相关链接 */ + links: RelayLinks; + /** API 配置 */ + api: RelayApi; + /** 联系方式 */ + contact: RelayContact; + /** 功能特性 */ + features: RelayFeatures; +} + +/** + * Deep Link 处理结果(从后端接收的事件 payload) + */ +export interface DeepLinkResult { + /** 解析后的 payload */ + payload: ConnectPayload; + /** 中转商信息(如果在注册表中找到) */ + relay_info: RelayInfo | null; + /** 是否为已验证的中转商 */ + is_verified: boolean; +} + +/** + * 保存 API Key 的返回结果 + */ +export interface SaveApiKeyResult { + /** Provider ID */ + provider_id: string; + /** API Key ID */ + key_id: string; + /** Provider 名称 */ + provider_name: string; + /** 是否为新创建的 Provider */ + is_new_provider: boolean; +} + +/** + * Connect 错误 + */ +export interface ConnectError { + code: string; + message: string; +} + +/** + * useDeepLink Hook 返回值 + */ +export interface UseDeepLinkReturn { + /** 解析后的 Deep Link payload */ + connectPayload: ConnectPayload | null; + /** 中转商信息(如果在注册表中找到) */ + relayInfo: RelayInfo | null; + /** 是否为已验证的中转商 */ + isVerified: boolean; + /** 弹窗是否打开 */ + isDialogOpen: boolean; + /** 是否正在保存 */ + isSaving: boolean; + /** 错误信息 */ + error: ConnectError | null; + /** 确认添加 API Key */ + handleConfirm: () => Promise; + /** 取消添加 */ + handleCancel: () => void; + /** 清除错误 */ + clearError: () => void; +} + +/** + * Deep Link 事件处理 Hook + * + * 监听来自 Tauri 后端的 deep-link-connect 事件,管理 Connect 弹窗状态。 + * + * ## 功能 + * + * - 监听 `deep-link-connect` 事件(Requirements 5.1) + * - 触发 Connect_Dialog 打开(Requirements 5.2) + * - 提供解析后的 Deep Link 参数(Requirements 5.3) + * - 关闭时清理临时状态(Requirements 5.4) + * + * ## 使用示例 + * + * ```tsx + * function App() { + * const { + * connectPayload, + * relayInfo, + * isDialogOpen, + * handleConfirm, + * handleCancel, + * } = useDeepLink(); + * + * return ( + * + * ); + * } + * ``` + * + * @returns Hook 返回值 + */ +export function useDeepLink(): UseDeepLinkReturn { + // 状态 + const [connectPayload, setConnectPayload] = useState( + null, + ); + const [relayInfo, setRelayInfo] = useState(null); + const [isVerified, setIsVerified] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + // 用于清理的 ref + const unlistenRef = useRef(null); + const unlistenErrorRef = useRef(null); + + // 统计回调 hook + const { sendSuccessCallback, sendCancelledCallback, sendErrorCallback } = + useConnectCallback(); + + /** + * 处理 deep-link-error 事件 + * _Requirements: 7.1_ + */ + const handleDeepLinkError = useCallback( + (error: { code: string; message: string }) => { + console.error("[useDeepLink] 收到 deep-link-error 事件:", error); + // 显示 Toast 错误提示 + showDeepLinkError(error.message, error.code); + }, + [], + ); + + /** + * 处理 deep-link-connect 事件 + * _Requirements: 5.1, 5.2, 5.3_ + */ + const handleDeepLinkEvent = useCallback((result: DeepLinkResult) => { + console.log("[useDeepLink] 收到 deep-link-connect 事件:", result); + + // 设置状态 + setConnectPayload(result.payload); + setRelayInfo(result.relay_info); + setIsVerified(result.is_verified); + setError(null); + + // 打开弹窗 + // _Requirements: 5.2_ + setIsDialogOpen(true); + }, []); + + /** + * 确认添加 API Key + * _Requirements: 5.4, 5.3 (统计回调)_ + */ + const handleConfirm = useCallback(async () => { + if (!connectPayload) { + console.warn("[useDeepLink] handleConfirm: connectPayload 为空"); + return; + } + + setIsSaving(true); + setError(null); + + try { + // 调用后端保存 API Key(添加到 API Key Provider 系统) + const result = await invoke("save_relay_api_key", { + relayId: connectPayload.relay, + apiKey: connectPayload.key, + name: connectPayload.name ?? null, + }); + + console.log( + "[useDeepLink] API Key 保存成功,Provider ID:", + result.provider_id, + "Key ID:", + result.key_id, + ); + + // 发送成功回调(异步,不阻塞) + // _Requirements: 5.3_ + sendSuccessCallback( + connectPayload.relay, + connectPayload.key, + connectPayload.ref_code, + ); + + // 关闭弹窗并清理状态 + // _Requirements: 5.4_ + setIsDialogOpen(false); + setConnectPayload(null); + setRelayInfo(null); + setIsVerified(false); + } catch (err) { + console.error("[useDeepLink] 保存 API Key 失败:", err); + // 设置错误,但不关闭弹窗 + // _Requirements: 7.4_ + const connectError = err as ConnectError; + setError(connectError); + // 显示 Toast 错误提示 + showApiKeySaveError(connectError.message); + + // 发送错误回调 + // _Requirements: 5.3_ + sendErrorCallback( + connectPayload.relay, + connectPayload.key, + connectError.code, + connectError.message, + connectPayload.ref_code, + ); + } finally { + setIsSaving(false); + } + }, [connectPayload, sendSuccessCallback, sendErrorCallback]); + + /** + * 取消添加 + * _Requirements: 5.4, 5.3 (统计回调)_ + */ + const handleCancel = useCallback(() => { + console.log("[useDeepLink] 用户取消添加"); + + // 发送取消回调(异步,不阻塞) + // _Requirements: 5.3_ + if (connectPayload) { + sendCancelledCallback( + connectPayload.relay, + connectPayload.key, + connectPayload.ref_code, + ); + } + + // 关闭弹窗并清理状态 + // _Requirements: 5.4_ + setIsDialogOpen(false); + setConnectPayload(null); + setRelayInfo(null); + setIsVerified(false); + setError(null); + }, [connectPayload, sendCancelledCallback]); + + /** + * 清除错误 + */ + const clearError = useCallback(() => { + setError(null); + }, []); + + // 监听 Deep Link URL 和后端事件 + // _Requirements: 5.1, 7.1_ + useEffect(() => { + let mounted = true; + let unlistenDeepLink: (() => void) | null = null; + + const setupListener = async () => { + try { + // 使用 @tauri-apps/plugin-deep-link 监听 Deep Link URL + // _Requirements: 5.1_ + unlistenDeepLink = await onOpenUrl(async (urls) => { + if (!mounted) return; + + console.log("[useDeepLink] 收到 Deep Link URL:", urls); + + for (const url of urls) { + if (url.startsWith("proxycast://connect")) { + try { + // 调用后端处理 Deep Link + const result = await invoke( + "handle_deep_link", + { url }, + ); + if (mounted) { + handleDeepLinkEvent(result); + } + } catch (err) { + console.error("[useDeepLink] 处理 Deep Link 失败:", err); + if (mounted) { + const connectError = err as ConnectError; + showDeepLinkError(connectError.message, connectError.code); + } + } + } + } + }); + + // 监听后端发送的 deep-link-connect 事件(兼容旧逻辑) + const unlisten = await listen( + "deep-link-connect", + (event) => { + if (mounted) { + handleDeepLinkEvent(event.payload); + } + }, + ); + + // 监听 deep-link-error 事件 + // _Requirements: 7.1_ + const unlistenError = await listen<{ code: string; message: string }>( + "deep-link-error", + (event) => { + if (mounted) { + handleDeepLinkError(event.payload); + } + }, + ); + + if (mounted) { + unlistenRef.current = unlisten; + unlistenErrorRef.current = unlistenError; + console.log("[useDeepLink] 已注册 Deep Link 和事件监听器"); + } else { + // 如果组件已卸载,立即取消监听 + unlisten(); + unlistenError(); + if (unlistenDeepLink) unlistenDeepLink(); + } + } catch (err) { + console.error("[useDeepLink] 注册监听器失败:", err); + } + }; + + setupListener(); + + // 清理函数 + return () => { + mounted = false; + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + if (unlistenErrorRef.current) { + unlistenErrorRef.current(); + unlistenErrorRef.current = null; + } + if (unlistenDeepLink) { + unlistenDeepLink(); + } + console.log("[useDeepLink] 已取消 Deep Link 监听器"); + }; + }, [handleDeepLinkEvent, handleDeepLinkError]); + + return { + connectPayload, + relayInfo, + isVerified, + isDialogOpen, + isSaving, + error, + handleConfirm, + handleCancel, + clearError, + }; +} + +export default useDeepLink;