From bffc26dae59f8fcc43e93ab03e6f254c3d542adb Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:32:58 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(config-watch):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=AE=88=E6=8A=A4=E7=B3=BB=E7=BB=9F=E5=92=8C?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E9=80=9A=E7=9F=A5=E9=98=9F=E5=88=97=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:新增配置快照和变更日志管理模块(changelogs.rs、snapshots.rs) - 后端:实现双模式监听(默认模式仅通知敏感字段,全量模式通知所有变更) - 后端:添加完整的差异分析引擎,支持字段变更检测、通配符模式匹配和黑名单过滤 - 后端:实现 Block/Allow 操作,支持恢复快照和更新快照 - 前端:实现变更通知队列机制,解决多工具连续修改时弹窗丢失问题 - 前端:新增配置守护管理界面(ConfigGuardTab)和变更历史查看(ChangeLogDialog) - 前端:支持敏感字段和黑名单配置管理(FieldManagementDialog) - 重构:删除旧的 watcher_commands.rs,功能整合到 config_commands.rs - 重构:启动流程更新,自动初始化配置快照和启动文件监听 - 测试:新增 9 个差异分析和模式匹配单元测试 --- CLAUDE.md | 43 +- src-tauri/src/commands/config_commands.rs | 348 ++++++- src-tauri/src/commands/mod.rs | 2 - src-tauri/src/commands/onboarding.rs | 1 + src-tauri/src/commands/watcher_commands.rs | 125 --- src-tauri/src/core/http.rs | 2 + src-tauri/src/data/changelogs.rs | 155 +++ src-tauri/src/data/mod.rs | 2 + src-tauri/src/data/snapshots.rs | 132 +++ src-tauri/src/main.rs | 72 +- src-tauri/src/models/config.rs | 129 +++ src-tauri/src/models/tool.rs | 10 + src-tauri/src/services/config/mod.rs | 7 +- src-tauri/src/services/config/types.rs | 12 + src-tauri/src/services/config/watcher.rs | 963 +++++++++--------- .../src/services/migration_manager/manager.rs | 1 + .../migrations/profile_v2.rs | 2 + .../src/services/profile_manager/manager.rs | 52 + .../src/services/profile_manager/types.rs | 8 + src-tauri/src/services/proxy/proxy_service.rs | 3 + src-tauri/src/setup/initialization.rs | 34 +- src/App.tsx | 31 +- src/components/dialogs/ChangeLogDialog.tsx | 273 +++++ src/components/dialogs/ConfigChangeDialog.tsx | 253 +++++ .../dialogs/FieldManagementDialog.tsx | 319 ++++++ src/components/layout/AppSidebar.tsx | 2 + src/hooks/useConfigWatch.ts | 109 ++ src/lib/tauri-commands/config-watch.ts | 103 ++ src/lib/tauri-commands/config.ts | 67 -- src/lib/tauri-commands/index.ts | 3 + src/lib/tauri-commands/types.ts | 18 - src/pages/AboutPage/index.tsx | 99 ++ .../SettingsPage/components/AboutTab.tsx | 89 -- .../components/ConfigGuardTab.tsx | 255 +++++ .../components/ConfigManagementTab.tsx | 340 ------- src/pages/SettingsPage/index.tsx | 47 +- src/types/config-watch.ts | 126 +++ 37 files changed, 2998 insertions(+), 1239 deletions(-) delete mode 100644 src-tauri/src/commands/watcher_commands.rs create mode 100644 src-tauri/src/data/changelogs.rs create mode 100644 src-tauri/src/data/snapshots.rs create mode 100644 src/components/dialogs/ChangeLogDialog.tsx create mode 100644 src/components/dialogs/ConfigChangeDialog.tsx create mode 100644 src/components/dialogs/FieldManagementDialog.tsx create mode 100644 src/hooks/useConfigWatch.ts create mode 100644 src/lib/tauri-commands/config-watch.ts create mode 100644 src/pages/AboutPage/index.tsx delete mode 100644 src/pages/SettingsPage/components/AboutTab.tsx create mode 100644 src/pages/SettingsPage/components/ConfigGuardTab.tsx delete mode 100644 src/pages/SettingsPage/components/ConfigManagementTab.tsx create mode 100644 src/types/config-watch.ts diff --git a/CLAUDE.md b/CLAUDE.md index e4263c2..d62b602 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,8 +97,20 @@ last-updated: 2025-12-16 - `claude.rs`:Claude Code 配置管理(4个公共函数,实现 `ToolConfigManager` trait,177行) - `codex.rs`:Codex 配置管理(支持 config.toml + auth.json,保留 TOML 格式,204行) - `gemini.rs`:Gemini CLI 配置管理(支持 settings.json + .env 环境变量,199行) - - `watcher.rs`:外部变更检测 + 文件监听(合并原 `config_watcher.rs`,550行) - - 变更检测:`detect_external_changes`、`mark_external_change`、`acknowledge_external_change` + - `watcher.rs`:外部变更检测 + 文件监听 + 差异分析(合并原 `config_watcher.rs`,~650行) + - **差异分析(2026-01-07)**: + - `compute_diff()`:递归比较 JSON 对象,检测字段变更/新增/删除 + - `matches_pattern()`:支持通配符模式匹配(如 `model_providers.*.base_url`) + - `is_sensitive_change()`:判断变更是否包含敏感字段(API Key、Base URL 等) + - `filter_blacklisted()`:过滤黑名单字段(如 theme、ui.\*、model 等自动修改字段) + - **双模式监听**: + - **默认模式(Default)**:仅通知敏感字段变更(API Key/URL),忽略其他变更 + - **全量模式(Full)**:通知所有非黑名单字段的变更 + - **变更处理**: + - `mark_external_change()`:增强版,包含完整差异分析和模式判断 + - `block_external_change`:恢复快照到原生配置文件 + - `allow_external_change`:更新快照为当前文件内容 + - 变更检测:`detect_external_changes`、`acknowledge_external_change` - Profile 导入:`import_external_change` - 文件监听:`ConfigWatcher`(轮询,跨平台)、`NotifyWatcherManager`(notify,高性能) - 核心函数:`config_paths`(返回主配置 + 附属文件)、`compute_native_checksum`(SHA256 校验和) @@ -106,7 +118,25 @@ last-updated: 2025-12-16 - 废弃功能:删除 `ConfigService::save_backup` 系列函数(由 `ProfileManager` 替代) - 变更检测:与 `ProfileManager` 集成,自动同步激活状态的 dirty 标记和 checksum - 命令层更新:`commands/config_commands.rs` 使用新模块路径(`config::claude::*`、`config::codex::*`、`config::gemini::*`) - - 测试状态:12 个测试(2 个轮询监听测试通过,10 个标记为 #[ignore],需 ProfileManager 重写) + - **Tauri 命令(2026-01-07)**:新增配置监听控制命令 + - `block_external_change`:阻止外部变更,恢复快照到原生配置文件 + - `allow_external_change`:允许外部变更,更新快照为当前文件内容 + - `get_watch_config`:获取监听配置(模式、黑名单、敏感字段) + - `update_watch_config`:更新监听配置 + - **前端实现(2026-01-07)**: + - **类型定义**:`types/config-watch.ts` 定义 `WatchMode`、`ConfigWatchConfig`、`ExternalConfigChange` 等类型 + - **命令包装**:`lib/tauri-commands/config-watch.ts` 提供 4 个命令的 TypeScript 封装 + - **配置变更对话框**:`components/dialogs/ConfigChangeDialog.tsx` 显示变更详情,提供阻止/允许操作 + - 展示工具名称、文件路径、变更字段列表(标记新增/删除) + - 敏感变更带警告图标和 Badge 标记 + - 操作说明区域解释阻止和允许的含义 + - **配置监听 Hook**:`hooks/useConfigWatch.ts` 监听 `config-external-change` 事件,管理对话框状态 + - **全局集成**:`App.tsx` 使用 `useConfigWatch` hook,自动弹出配置变更对话框 + - **设置页面**:`pages/SettingsPage/components/ConfigWatchTab.tsx` 提供监听配置管理界面 + - 双模式选择(默认模式/全量模式) + - 敏感字段列表展示(按工具分组) + - 黑名单字段列表展示(按工具分组) + - 测试状态:15 个测试(11 个通过,包含 9 个差异分析/黑名单/模式匹配测试,4 个旧测试标记为 #[ignore] 待 ProfileManager 重写) - **工具管理系统**: - 多环境架构:支持本地(Local)、WSL、SSH 三种环境的工具实例管理 - 数据模型:`ToolType`(环境类型)、`ToolInstance`(工具实例)存储在 `models/tool.rs` @@ -209,6 +239,11 @@ last-updated: 2025-12-16 - `ToolProxyConfig` 额外存储 `real_profile_name`、`auto_start`、工具级 `session_endpoint_config_enabled`,全局配置新增 `hide_transparent_proxy_tip` 控制设置页横幅显示 - `GlobalConfig.hide_session_config_hint` 持久化会话级端点提示的隐藏状态,`ProxyControlBar`/`ProxySettingsDialog`/`ClaudeContent` 通过 `open-proxy-settings` 与 `proxy-config-updated` 事件联动刷新视图 - 日志系统支持完整配置管理:`GlobalConfig.log_config` 存储级别/格式/输出目标;`log_commands.rs` 提供查询与更新命令,`LogSettingsTab` 可热重载级别、保存文件输出设置;`core/logger.rs` 通过 `update_log_level` reload 机制动态调整 +- **配置监听系统(2026-01-07)**:`GlobalConfig.config_watch: ConfigWatchConfig` 管理文件监听行为 + - `mode: WatchMode`:监听模式(Default=仅敏感字段,Full=全量变更) + - `blacklist: HashMap>`:按工具分组的黑名单字段(如 `theme.*`、`ui.*`、`model`),自动过滤不需通知的变更 + - `sensitive_fields: HashMap>`:按工具分组的敏感字段(API Key、Base URL 等),用于默认模式判断 + - 配合 `ActiveProfile.native_snapshot` 实现 Block/Allow 操作 - 应用启动时 `duckcoding::auto_start_proxies` 会读取配置,满足 `enabled && auto_start` 且存在 `local_api_key` 的代理会自动启动 - `utils::config::migrate_session_config` 会将旧版 `GlobalConfig.session_endpoint_config_enabled` 自动迁移到各工具配置,确保升级过程不会丢开关 - 全局配置读写统一走 `utils::config::{read_global_config, write_global_config, apply_proxy_if_configured}`,避免出现多份路径逻辑;任何命令要修改配置都应调用这些辅助函数。 @@ -252,6 +287,8 @@ last-updated: 2025-12-16 - **数据结构**: - `ProfilesStore`:按工具分组(`claude_code`、`codex`、`gemini_cli`),每个工具包含 `HashMap` - `ActiveStore`:每个工具一个 `Option`,记录当前激活的 Profile 名称和切换时间 + - `native_snapshot`(2026-01-07):保存激活 Profile 应用后的完整原生配置快照(JSON),用于阻止外部变更时恢复 + - `last_synced_at`(2026-01-07):最后同步时间戳,配合 dirty 标记判断配置状态 - `ProfilePayload`:Enum 类型,支持 Claude/Codex/Gemini 三种变体,存储工具特定配置和原生文件快照 - **核心服务**(位于 `services/profile_manager/`): - `ProfileManager`:统一的 Profile CRUD 接口,支持列表、创建、更新、删除、激活、导入导出 diff --git a/src-tauri/src/commands/config_commands.rs b/src-tauri/src/commands/config_commands.rs index 7dd1ece..d33064b 100644 --- a/src-tauri/src/commands/config_commands.rs +++ b/src-tauri/src/commands/config_commands.rs @@ -1,16 +1,14 @@ // 配置管理相关命令 -use super::error::{AppError, AppResult}; use serde_json::Value; use ::duckcoding::services::config::{ - self, claude, codex, gemini, ClaudeSettingsPayload, CodexSettingsPayload, ExternalConfigChange, - GeminiEnvPayload, GeminiSettingsPayload, ImportExternalChangeResult, + claude, codex, gemini, ClaudeSettingsPayload, CodexSettingsPayload, GeminiEnvPayload, + GeminiSettingsPayload, }; use ::duckcoding::services::proxy::config::apply_global_proxy; use ::duckcoding::utils::config::{read_global_config, write_global_config}; use ::duckcoding::GlobalConfig; -use ::duckcoding::Tool; // ==================== 类型定义 ==================== @@ -48,32 +46,6 @@ fn build_reqwest_client() -> Result { // ==================== Tauri 命令 ==================== -/// 检测外部配置变更 -#[tauri::command] -pub async fn get_external_changes() -> Result, String> { - config::detect_external_changes().map_err(|e| e.to_string()) -} - -/// 确认外部变更(清除脏标记并刷新 checksum) -#[tauri::command] -pub async fn ack_external_change(tool: String) -> AppResult<()> { - let tool_obj = - Tool::by_id(&tool).ok_or_else(|| AppError::ToolNotFound { tool: tool.clone() })?; - Ok(config::acknowledge_external_change(&tool_obj)?) -} - -/// 将外部修改导入集中仓 -#[tauri::command] -pub async fn import_native_change( - tool: String, - profile: String, - as_new: bool, -) -> AppResult { - let tool_obj = - Tool::by_id(&tool).ok_or_else(|| AppError::ToolNotFound { tool: tool.clone() })?; - Ok(config::import_external_change(&tool_obj, &profile, as_new)?) -} - #[tauri::command] pub async fn save_global_config(config: GlobalConfig) -> Result<(), String> { write_global_config(&config) @@ -287,3 +259,319 @@ pub async fn update_single_instance_config(enabled: bool) -> Result<(), String> Ok(()) } + +// ==================== 配置监听命令 ==================== + +/// 阻止外部变更(恢复到快照) +/// +/// # Arguments +/// +/// * `tool_id` - 工具 ID +/// +/// # Returns +/// +/// 操作成功返回 Ok +#[tauri::command] +pub fn block_external_change(tool_id: String) -> Result<(), String> { + use ::duckcoding::data::snapshots; + use ::duckcoding::data::DataManager; + use ::duckcoding::models::Tool; + + // 获取快照 + let snapshot = snapshots::get_snapshot(&tool_id) + .map_err(|e| format!("读取快照失败: {}", e))? + .ok_or_else(|| "没有可用的配置快照".to_string())?; + + // 获取工具定义 + let tool = Tool::by_id(&tool_id).ok_or_else(|| format!("未找到工具: {}", tool_id))?; + let manager = DataManager::new(); + + // 恢复所有配置文件 + for (filename, content) in &snapshot.files { + let config_path = tool.config_dir.join(filename); + + if filename.ends_with(".json") { + // JSON 文件:直接写入 + manager + .json_uncached() + .write(&config_path, content) + .map_err(|e| format!("恢复 {} 失败: {}", filename, e))?; + } else if filename.ends_with(".toml") { + // TOML 文件:将 JSON 转换回 TOML + let toml_value: toml::Value = serde_json::from_value(content.clone()) + .map_err(|e| format!("JSON 转 TOML 失败: {}", e))?; + let toml_str = + toml::to_string(&toml_value).map_err(|e| format!("TOML 序列化失败: {}", e))?; + std::fs::write(&config_path, toml_str) + .map_err(|e| format!("写入 {} 失败: {}", filename, e))?; + } else if filename.ends_with(".env") || filename == ".env" { + // ENV 文件:将 JSON 转换回键值对 + let env_map: std::collections::HashMap = + serde_json::from_value(content.clone()) + .map_err(|e| format!("JSON 转 ENV 失败: {}", e))?; + manager + .env() + .write(&config_path, &env_map) + .map_err(|e| format!("恢复 {} 失败: {}", filename, e))?; + } else { + tracing::warn!("不支持的配置文件格式: {}", filename); + } + } + + // 更新日志记录 + use ::duckcoding::data::changelogs::ChangeLogStore; + let mut store = ChangeLogStore::load().map_err(|e| format!("加载日志失败: {}", e))?; + if let Err(e) = store.update_action(&tool_id, "block") { + tracing::warn!("更新日志记录失败: {}", e); + } else { + store.save().map_err(|e| format!("保存日志失败: {}", e))?; + } + + tracing::info!(tool_id = %tool_id, "已阻止外部变更并恢复所有配置文件"); + + Ok(()) +} + +/// 允许外部变更(更新快照) +/// +/// # Arguments +/// +/// * `tool_id` - 工具 ID +/// +/// # Returns +/// +/// 操作成功返回 Ok +#[tauri::command] +pub fn allow_external_change(tool_id: String) -> Result<(), String> { + use ::duckcoding::models::Tool; + + let tool = Tool::by_id(&tool_id).ok_or_else(|| format!("未找到工具: {}", tool_id))?; + + // 重新保存快照(读取所有配置文件) + ::duckcoding::services::config::watcher::save_snapshot_for_tool(&tool) + .map_err(|e| format!("保存快照失败: {}", e))?; + + // 更新日志记录 + use ::duckcoding::data::changelogs::ChangeLogStore; + let mut store = ChangeLogStore::load().map_err(|e| format!("加载日志失败: {}", e))?; + if let Err(e) = store.update_action(&tool_id, "allow") { + tracing::warn!("更新日志记录失败: {}", e); + } else { + store.save().map_err(|e| format!("保存日志失败: {}", e))?; + } + + tracing::info!(tool_id = %tool_id, "已允许外部变更并更新所有配置文件快照"); + + Ok(()) +} + +/// 获取监听配置 +#[tauri::command] +pub fn get_watch_config() -> Result<::duckcoding::models::config::ConfigWatchConfig, String> { + let config = read_global_config() + .map_err(|e| format!("读取配置失败: {e}"))? + .ok_or("配置文件不存在")?; + Ok(config.config_watch) +} + +/// 更新监听配置 +#[tauri::command] +pub fn update_watch_config( + config: ::duckcoding::models::config::ConfigWatchConfig, +) -> Result<(), String> { + let mut global_config = read_global_config() + .map_err(|e| format!("读取配置失败: {e}"))? + .ok_or("配置文件不存在")?; + global_config.config_watch = config; + write_global_config(&global_config).map_err(|e| format!("保存配置失败: {e}"))?; + + tracing::info!("配置监听配置已更新"); + + Ok(()) +} + +// ==================== 配置守护管理命令 ==================== + +/// 更新敏感字段配置 +/// +/// # Arguments +/// +/// * `tool_id` - 工具 ID +/// * `fields` - 敏感字段列表 +#[tauri::command] +pub fn update_sensitive_fields(tool_id: String, fields: Vec) -> Result<(), String> { + let mut config = read_global_config() + .map_err(|e| format!("读取配置失败: {e}"))? + .ok_or("配置文件不存在")?; + + config + .config_watch + .sensitive_fields + .insert(tool_id.clone(), fields); + + write_global_config(&config).map_err(|e| format!("保存配置失败: {e}"))?; + + tracing::info!(tool_id = %tool_id, "敏感字段配置已更新"); + + Ok(()) +} + +/// 更新黑名单配置 +/// +/// # Arguments +/// +/// * `tool_id` - 工具 ID +/// * `fields` - 黑名单字段列表 +#[tauri::command] +pub fn update_blacklist(tool_id: String, fields: Vec) -> Result<(), String> { + let mut config = read_global_config() + .map_err(|e| format!("读取配置失败: {e}"))? + .ok_or("配置文件不存在")?; + + config + .config_watch + .blacklist + .insert(tool_id.clone(), fields); + + write_global_config(&config).map_err(|e| format!("保存配置失败: {e}"))?; + + tracing::info!(tool_id = %tool_id, "黑名单配置已更新"); + + Ok(()) +} + +/// 获取默认敏感字段配置 +#[tauri::command] +pub fn get_default_sensitive_fields( +) -> Result>, String> { + use ::duckcoding::models::config::default_sensitive_fields; + Ok(default_sensitive_fields()) +} + +/// 获取默认黑名单配置 +#[tauri::command] +pub fn get_default_blacklist() -> Result>, String> { + use ::duckcoding::models::config::default_watch_blacklist; + Ok(default_watch_blacklist()) +} + +// ==================== 变更日志管理命令 ==================== + +/// 获取配置变更日志 +/// +/// # Arguments +/// +/// * `tool_id` - 工具 ID(可选,不传则返回所有工具的日志) +/// * `limit` - 返回条数限制(默认 50) +#[tauri::command] +pub fn get_change_logs( + tool_id: Option, + limit: Option, +) -> Result, String> { + use ::duckcoding::data::changelogs::ChangeLogStore; + + let store = ChangeLogStore::load().map_err(|e| format!("读取日志失败: {e}"))?; + let limit = limit.unwrap_or(50); + let tool_id_ref = tool_id.as_deref(); + + let records: Vec<_> = store + .get_recent(tool_id_ref, limit) + .into_iter() + .cloned() + .collect(); + + Ok(records) +} + +/// 分页获取配置变更日志 +/// +/// # Arguments +/// +/// * `page` - 页码(从 0 开始) +/// * `page_size` - 每页条数 +/// +/// # Returns +/// +/// 返回 (records, total) 元组 +#[tauri::command] +pub fn get_change_logs_page( + page: usize, + page_size: usize, +) -> Result< + ( + Vec<::duckcoding::data::changelogs::ConfigChangeRecord>, + usize, + ), + String, +> { + use ::duckcoding::data::changelogs::ChangeLogStore; + + let store = ChangeLogStore::load().map_err(|e| format!("读取日志失败: {e}"))?; + let (records, total) = store.get_page(page, page_size); + + Ok((records, total)) +} + +/// 清除配置变更日志 +/// +/// # Arguments +/// +/// * `tool_id` - 工具 ID(可选,不传则清除所有日志) +#[tauri::command] +pub fn clear_change_logs(tool_id: Option) -> Result<(), String> { + use ::duckcoding::data::changelogs::ChangeLogStore; + + let mut store = ChangeLogStore::load().map_err(|e| format!("读取日志失败: {e}"))?; + + if let Some(id) = tool_id { + store.clear_for_tool(&id); + tracing::info!(tool_id = %id, "已清除工具变更日志"); + } else { + store.clear_all(); + tracing::info!("已清除所有变更日志"); + } + + store.save().map_err(|e| format!("保存日志失败: {e}"))?; + + Ok(()) +} + +/// 更新变更日志的用户操作 +/// +/// # Arguments +/// +/// * `tool_id` - 工具 ID +/// * `timestamp` - 变更时间戳(ISO 8601 格式) +/// * `action` - 用户操作(allow/block) +#[tauri::command] +pub fn update_change_log_action( + tool_id: String, + timestamp: String, + action: String, +) -> Result<(), String> { + use ::duckcoding::data::changelogs::ChangeLogStore; + use chrono::{DateTime, Utc}; + + let mut store = ChangeLogStore::load().map_err(|e| format!("读取日志失败: {e}"))?; + let ts: DateTime = timestamp + .parse() + .map_err(|e| format!("时间戳格式错误: {e}"))?; + + // 查找并更新记录 + if let Some(record) = store + .records + .iter_mut() + .find(|r| r.tool_id == tool_id && r.timestamp == ts) + { + record.action = Some(action.clone()); + store.save().map_err(|e| format!("保存日志失败: {e}"))?; + tracing::info!( + tool_id = %tool_id, + action = %action, + "变更日志操作已更新" + ); + Ok(()) + } else { + Err("未找到匹配的变更记录".to_string()) + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c340bc1..1098bca 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -15,7 +15,6 @@ pub mod tool_commands; pub mod tool_management; pub mod types; pub mod update_commands; -pub mod watcher_commands; pub mod window_commands; // 重新导出所有命令函数 @@ -34,5 +33,4 @@ pub use token_commands::*; // 令牌资产管理命令(NEW API 集成) pub use tool_commands::*; pub use tool_management::*; pub use update_commands::*; -pub use watcher_commands::*; pub use window_commands::*; diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 5a596b5..782d361 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -28,6 +28,7 @@ fn create_minimal_config() -> GlobalConfig { external_poll_interval_ms: 5000, single_instance_enabled: true, startup_enabled: false, + config_watch: duckcoding::models::config::ConfigWatchConfig::default(), } } diff --git a/src-tauri/src/commands/watcher_commands.rs b/src-tauri/src/commands/watcher_commands.rs deleted file mode 100644 index 9bed5e2..0000000 --- a/src-tauri/src/commands/watcher_commands.rs +++ /dev/null @@ -1,125 +0,0 @@ -use duckcoding::services::config::NotifyWatcherManager; -use duckcoding::utils::config::{read_global_config, write_global_config}; -use tauri::AppHandle; -use tracing::{debug, error, warn}; - -use crate::ExternalWatcherState; - -/// 获取当前监听状态 -#[tauri::command] -pub async fn get_watcher_status( - state: tauri::State<'_, ExternalWatcherState>, -) -> Result { - let guard = state - .manager - .lock() - .map_err(|e| format!("锁定 watcher 状态失败: {e}"))?; - let running = guard.is_some(); - debug!(running, "Watcher status queried"); - Ok(running) -} - -/// 按需开启监听 -#[tauri::command] -pub async fn start_watcher_if_needed( - app: AppHandle, - state: tauri::State<'_, ExternalWatcherState>, -) -> Result { - { - let guard = state - .manager - .lock() - .map_err(|e| format!("锁定 watcher 状态失败: {e}"))?; - if guard.is_some() { - debug!("Watcher already running, skip start"); - return Ok(true); - } - } - - // 检查全局配置是否允许 - if let Ok(Some(cfg)) = read_global_config() { - if !cfg.external_watch_enabled { - warn!("Global config disabled external watch, skip start"); - return Err("已在全局配置中关闭监听".to_string()); - } - debug!( - enabled = cfg.external_watch_enabled, - poll_interval_ms = cfg.external_poll_interval_ms, - "Watcher start check: config loaded" - ); - } - - let manager = NotifyWatcherManager::start_all(app.clone()).map_err(|e| { - error!(error = ?e, "Failed to start notify watchers"); - e.to_string() - })?; - let mut guard = state - .manager - .lock() - .map_err(|e| format!("锁定 watcher 状态失败: {e}"))?; - *guard = Some(manager); - debug!("Watcher started and manager stored"); - Ok(true) -} - -/// 停止监听 -#[tauri::command] -pub async fn stop_watcher(state: tauri::State<'_, ExternalWatcherState>) -> Result { - let mut guard = state - .manager - .lock() - .map_err(|e| format!("锁定 watcher 状态失败: {e}"))?; - if guard.is_none() { - warn!("Stop watcher called but watcher not running"); - return Ok(false); - } - *guard = None; - debug!("Watcher stopped and manager cleared"); - Ok(true) -} - -/// 同步保存监听开关并尝试应用(开启时立即启动 watcher;关闭时停止) -#[tauri::command] -pub async fn save_watcher_settings( - app: AppHandle, - state: tauri::State<'_, ExternalWatcherState>, - enabled: bool, - poll_interval_ms: Option, -) -> Result<(), String> { - let mut cfg = read_global_config() - .map_err(|e| format!("读取全局配置失败: {e}"))? - .ok_or_else(|| "全局配置不存在,无法保存监听设置".to_string())?; - let old_enabled = cfg.external_watch_enabled; - let old_interval = cfg.external_poll_interval_ms; - cfg.external_watch_enabled = enabled; - if let Some(interval) = poll_interval_ms { - cfg.external_poll_interval_ms = interval; - } - write_global_config(&cfg).map_err(|e| format!("保存全局配置失败: {e}"))?; - - if enabled { - debug!( - enabled, - poll_interval_ms = cfg.external_poll_interval_ms, - old_enabled, - old_interval, - "Saving watcher settings: starting watcher" - ); - let started = start_watcher_if_needed(app, state).await?; - if !started { - error!("Watcher should start but start_watcher_if_needed returned false"); - return Err("监听未能启动".to_string()); - } - } else { - debug!( - enabled, - poll_interval_ms = cfg.external_poll_interval_ms, - old_enabled, - old_interval, - "Saving watcher settings: stopping watcher" - ); - let _ = stop_watcher(state).await?; - } - - Ok(()) -} diff --git a/src-tauri/src/core/http.rs b/src-tauri/src/core/http.rs index 40208d9..17e34d4 100644 --- a/src-tauri/src/core/http.rs +++ b/src-tauri/src/core/http.rs @@ -114,6 +114,7 @@ mod tests { external_poll_interval_ms: 5000, single_instance_enabled: true, startup_enabled: false, + config_watch: crate::models::config::ConfigWatchConfig::default(), }; let url = build_proxy_url(&config).unwrap(); @@ -143,6 +144,7 @@ mod tests { external_poll_interval_ms: 5000, single_instance_enabled: true, startup_enabled: false, + config_watch: crate::models::config::ConfigWatchConfig::default(), }; let url = build_proxy_url(&config).unwrap(); diff --git a/src-tauri/src/data/changelogs.rs b/src-tauri/src/data/changelogs.rs new file mode 100644 index 0000000..ea1b245 --- /dev/null +++ b/src-tauri/src/data/changelogs.rs @@ -0,0 +1,155 @@ +//! 配置变更日志模块 +//! +//! 记录所有配置变更的历史,包含变更前后的值 + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; + +/// 变更日志文件名 +const CHANGE_LOG_FILE: &str = "config_watch_logs.json"; + +/// 单条变更记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigChangeRecord { + /// 工具 ID + pub tool_id: String, + /// 变更时间 + pub timestamp: DateTime, + /// 变更字段列表 + pub changed_fields: Vec, + /// 是否包含敏感字段 + pub is_sensitive: bool, + /// 变更前的值(字段路径 -> 值) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub before_values: HashMap, + /// 变更后的值(字段路径 -> 值) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub after_values: HashMap, + /// 用户操作(allow/block/superseded/expired) + pub action: Option, +} + +/// 变更日志存储 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ChangeLogStore { + /// 变更记录列表(按时间倒序) + pub records: Vec, +} + +impl ChangeLogStore { + /// 最大日志条数 + const MAX_RECORDS: usize = 100; + + /// 获取日志文件路径 + pub fn file_path() -> Result { + let config_dir = crate::utils::config::config_dir() + .map_err(|e| anyhow::anyhow!("无法获取配置目录: {}", e))?; + Ok(config_dir.join(CHANGE_LOG_FILE)) + } + + /// 读取日志 + pub fn load() -> Result { + use crate::data::DataManager; + + let path = Self::file_path()?; + if !path.exists() { + return Ok(Self::default()); + } + + let manager = DataManager::new(); + let value = manager.json().read(&path)?; + let store: Self = serde_json::from_value(value)?; + Ok(store) + } + + /// 保存日志 + pub fn save(&self) -> Result<()> { + use crate::data::DataManager; + + let path = Self::file_path()?; + let manager = DataManager::new(); + let value = serde_json::to_value(self)?; + manager.json().write(&path, &value)?; + Ok(()) + } + + /// 添加变更记录 + pub fn add_record(&mut self, record: ConfigChangeRecord) { + // 检查同一工具是否有待处理的记录,如果有则标记为已累加 + if let Some(last_pending) = self + .records + .iter_mut() + .find(|r| r.tool_id == record.tool_id && r.action.is_none()) + { + last_pending.action = Some("superseded".to_string()); + } + + // 插入到开头(最新的在前面) + self.records.insert(0, record); + + // 限制日志条数 + if self.records.len() > Self::MAX_RECORDS { + self.records.truncate(Self::MAX_RECORDS); + } + } + + /// 更新指定工具的最新待处理记录的操作状态 + pub fn update_action(&mut self, tool_id: &str, action: &str) -> Result<()> { + if let Some(record) = self + .records + .iter_mut() + .find(|r| r.tool_id == tool_id && r.action.is_none()) + { + record.action = Some(action.to_string()); + Ok(()) + } else { + Err(anyhow::anyhow!("未找到待处理的变更记录")) + } + } + + /// 标记所有待处理的记录为已过期 + pub fn mark_pending_as_expired(&mut self) { + for record in self.records.iter_mut() { + if record.action.is_none() { + record.action = Some("expired".to_string()); + } + } + } + + /// 分页获取记录 + pub fn get_page(&self, page: usize, page_size: usize) -> (Vec, usize) { + let total = self.records.len(); + let start = page * page_size; + + if start >= total { + return (vec![], total); + } + + let end = (start + page_size).min(total); + let records = self.records[start..end].to_vec(); + (records, total) + } + + /// 获取指定工具的最近 N 条记录 + pub fn get_recent(&self, tool_id: Option<&str>, limit: usize) -> Vec<&ConfigChangeRecord> { + self.records + .iter() + .filter(|r| tool_id.is_none_or(|id| r.tool_id == id)) + .take(limit) + .collect() + } + + /// 清除指定工具的所有记录 + pub fn clear_for_tool(&mut self, tool_id: &str) { + self.records.retain(|r| r.tool_id != tool_id); + } + + /// 清除所有记录 + pub fn clear_all(&mut self) { + self.records.clear(); + } +} diff --git a/src-tauri/src/data/mod.rs b/src-tauri/src/data/mod.rs index b988538..7a94d9c 100644 --- a/src-tauri/src/data/mod.rs +++ b/src-tauri/src/data/mod.rs @@ -26,9 +26,11 @@ //! ``` pub mod cache; +pub mod changelogs; pub mod error; pub mod manager; pub mod managers; +pub mod snapshots; #[cfg(test)] mod migration_tests; diff --git a/src-tauri/src/data/snapshots.rs b/src-tauri/src/data/snapshots.rs new file mode 100644 index 0000000..5525c8a --- /dev/null +++ b/src-tauri/src/data/snapshots.rs @@ -0,0 +1,132 @@ +// 配置快照管理模块 + +use crate::data::DataManager; +use crate::models::config::ConfigSnapshot; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// 快照存储结构 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SnapshotStore { + /// 按工具 ID 存储的快照 + pub snapshots: HashMap, +} + +/// 获取快照文件路径 +fn snapshots_file() -> Result { + let config_dir = crate::utils::config::config_dir() + .map_err(|e| anyhow::anyhow!("无法获取配置目录: {}", e))?; + Ok(config_dir.join("config_snapshots.json")) +} + +/// 读取所有快照 +pub fn read_snapshots() -> Result { + let path = snapshots_file()?; + if !path.exists() { + return Ok(SnapshotStore::default()); + } + + let manager = DataManager::new(); + let value = manager.json().read(&path)?; + let store: SnapshotStore = serde_json::from_value(value)?; + Ok(store) +} + +/// 保存所有快照 +pub fn write_snapshots(store: &SnapshotStore) -> Result<()> { + let path = snapshots_file()?; + let manager = DataManager::new(); + let value = serde_json::to_value(store)?; + manager.json().write(&path, &value)?; + Ok(()) +} + +/// 获取单个工具的快照 +pub fn get_snapshot(tool_id: &str) -> Result> { + let store = read_snapshots()?; + Ok(store.snapshots.get(tool_id).cloned()) +} + +/// 保存单个工具的快照(多文件版本) +pub fn save_snapshot_files( + tool_id: &str, + files: std::collections::HashMap, +) -> Result<()> { + let mut store = read_snapshots()?; + let snapshot = ConfigSnapshot { + tool_id: tool_id.to_string(), + files, + last_updated: chrono::Utc::now(), + }; + store.snapshots.insert(tool_id.to_string(), snapshot); + write_snapshots(&store)?; + Ok(()) +} + +/// 保存单个工具的快照(单文件版本,用于向后兼容) +pub fn save_snapshot(tool_id: &str, content: serde_json::Value) -> Result<()> { + let mut files = std::collections::HashMap::new(); + // 默认使用主配置文件名 + let filename = match tool_id { + "claude-code" => "settings.json", + "codex" => "config.toml", + "gemini-cli" => "settings.json", + _ => "config.json", + }; + files.insert(filename.to_string(), content); + save_snapshot_files(tool_id, files) +} + +/// 删除单个工具的快照 +pub fn delete_snapshot(tool_id: &str) -> Result<()> { + let mut store = read_snapshots()?; + store.snapshots.remove(tool_id); + write_snapshots(&store)?; + Ok(()) +} + +/// 清空所有快照 +pub fn clear_snapshots() -> Result<()> { + let store = SnapshotStore::default(); + write_snapshots(&store)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_snapshot_crud() -> Result<()> { + // 清空快照 + clear_snapshots()?; + + // 保存快照 + let content = json!({ + "env": { + "API_KEY": "test_key" + } + }); + save_snapshot("claude-code", content.clone())?; + + // 读取快照 + let snapshot = get_snapshot("claude-code")?; + assert!(snapshot.is_some()); + let snapshot = snapshot.unwrap(); + assert_eq!(snapshot.tool_id, "claude-code"); + + // 检查快照内容(单文件模式,存储在 settings.json 键下) + assert!(snapshot.files.contains_key("settings.json")); + assert_eq!(snapshot.files.get("settings.json").unwrap(), &content); + + // 删除快照 + delete_snapshot("claude-code")?; + let snapshot = get_snapshot("claude-code")?; + assert!(snapshot.is_none()); + + Ok(()) + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 53357cf..aa33cec 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,12 +1,10 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use duckcoding::services::config::{NotifyWatcherManager, EXTERNAL_CHANGE_EVENT}; use duckcoding::services::proxy::config::apply_global_proxy; use duckcoding::utils::config::read_global_config; use serde::Serialize; use std::env; -use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager}; // 导入 commands 模块 @@ -18,10 +16,6 @@ mod setup; const SINGLE_INSTANCE_EVENT: &str = "single-instance"; -struct ExternalWatcherState { - manager: Mutex>, -} - #[derive(Clone, Serialize)] struct SingleInstancePayload { args: Vec, @@ -87,36 +81,18 @@ fn setup_working_directory(app: &tauri::App) -> tauri::Result<()> { /// 启动配置文件监听(如果启用) fn start_config_watcher(app: &tauri::App) -> tauri::Result<()> { - if let Some(state) = app.try_state::() { - let enable_watch = match read_global_config() { - Ok(Some(cfg)) => cfg.external_watch_enabled, - _ => true, - }; - if !enable_watch { - tracing::info!("External config watcher disabled by config"); - } + use duckcoding::services::config::{initialize_snapshots, start_watcher}; - if let Ok(mut guard) = state.manager.lock() { - if guard.is_none() && enable_watch { - match NotifyWatcherManager::start_all(app.handle().clone()) { - Ok(manager) => { - tracing::debug!( - "Config notify watchers started, emitting event {EXTERNAL_CHANGE_EVENT}" - ); - *guard = Some(manager); - } - Err(err) => { - tracing::error!("Failed to start notify watchers: {err:?}"); - } - } - } else { - tracing::info!( - already_running = guard.is_some(), - enable_watch, - "Skip starting notify watcher" - ); - } - } + // 初始化配置快照 + if let Err(e) = initialize_snapshots() { + tracing::warn!("初始化配置快照失败: {}", e); + } + + // 启动文件监听 + if let Err(e) = start_watcher(app.handle().clone()) { + tracing::error!("启动配置监听失败: {}", e); + } else { + tracing::info!("配置文件监听已启动"); } Ok(()) @@ -181,10 +157,6 @@ fn main() { setup::initialize_app().await.expect("应用初始化失败") }); - let watcher_state = ExternalWatcherState { - manager: Mutex::new(None), - }; - let proxy_manager_state = ProxyManagerState { manager: init_ctx.proxy_manager, }; @@ -214,7 +186,6 @@ fn main() { let builder = tauri::Builder::default() .manage(proxy_manager_state) - .manage(watcher_state) .manage(update_service_state) .manage(tool_registry_state) .manage(profile_manager_state) @@ -276,9 +247,6 @@ fn main() { save_global_config, get_global_config, generate_api_key_for_tool, - get_external_changes, - ack_external_change, - import_native_change, // 使用统计 get_usage_stats, get_user_quota, @@ -323,10 +291,20 @@ fn main() { update_session_config, update_session_note, // 配置监听控制 - get_watcher_status, - start_watcher_if_needed, - stop_watcher, - save_watcher_settings, + block_external_change, + allow_external_change, + get_watch_config, + update_watch_config, + // 配置守护管理 + update_sensitive_fields, + update_blacklist, + get_default_sensitive_fields, + get_default_blacklist, + // 变更日志管理 + get_change_logs, + get_change_logs_page, + clear_change_logs, + update_change_log_action, // 更新管理相关命令 check_for_app_updates, download_app_update, diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index c3cff10..c2b7e17 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -81,6 +81,64 @@ pub struct OnboardingStatus { pub completed_at: Option, } +/// 配置监听模式 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum WatchMode { + /// 默认模式:仅检测敏感字段(API Key/URL) + #[default] + Default, + /// 全量模式:检测所有变更 + Full, +} + +/// 配置监听配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigWatchConfig { + /// 是否启用配置守护 + #[serde(default = "default_config_guard_enabled")] + pub enabled: bool, + /// 监听模式 + #[serde(default)] + pub mode: WatchMode, + /// 扫描间隔(秒) + #[serde(default = "default_scan_interval")] + pub scan_interval: u64, + /// 黑名单字段(按工具分组) + /// 格式:{ "claude-code": ["env.model", "theme"], ... } + #[serde(default = "default_watch_blacklist")] + pub blacklist: HashMap>, + /// 敏感字段(按工具分组) + /// 格式:{ "claude-code": ["env.ANTHROPIC_AUTH_TOKEN", ...], ... } + #[serde(default = "default_sensitive_fields")] + pub sensitive_fields: HashMap>, +} + +/// 配置文件快照 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigSnapshot { + /// 工具 ID + pub tool_id: String, + /// 配置文件快照(文件名 -> 内容) + /// 对于 JSON 文件,直接存储 JSON 值 + /// 对于 TOML/ENV 文件,转换为 JSON 对象存储 + pub files: HashMap, + /// 最后更新时间 + pub last_updated: chrono::DateTime, +} + +impl Default for ConfigWatchConfig { + fn default() -> Self { + Self { + enabled: default_config_guard_enabled(), + mode: WatchMode::Default, + scan_interval: default_scan_interval(), + blacklist: default_watch_blacklist(), + sensitive_fields: default_sensitive_fields(), + } + } +} + impl LogConfig { /// 检查新配置是否可以热重载(无需重启应用) /// 只有日志级别变更可以热重载,其他配置需要重启 @@ -186,6 +244,9 @@ pub struct GlobalConfig { /// 开机自启动开关(默认关闭) #[serde(default)] pub startup_enabled: bool, + /// 配置监听配置 + #[serde(default)] + pub config_watch: ConfigWatchConfig, } fn default_proxy_configs() -> HashMap { @@ -303,3 +364,71 @@ fn default_external_poll_interval_ms() -> u64 { fn default_single_instance_enabled() -> bool { true } + +/// 默认配置守护启用状态 +fn default_config_guard_enabled() -> bool { + true +} + +/// 默认扫描间隔(秒) +fn default_scan_interval() -> u64 { + 2 +} + +pub fn default_watch_blacklist() -> HashMap> { + let mut map = HashMap::new(); + map.insert( + "claude-code".to_string(), + vec![ + "theme".to_string(), + "keyBindings".to_string(), + "ui.*".to_string(), + "editor.*".to_string(), + ], + ); + map.insert( + "codex".to_string(), + vec![ + "network_access".to_string(), + "ui.*".to_string(), + "editor.*".to_string(), + ], + ); + map.insert( + "gemini-cli".to_string(), + vec![ + "ui.*".to_string(), + "cache.*".to_string(), + "log_level".to_string(), + ], + ); + map +} + +pub fn default_sensitive_fields() -> HashMap> { + let mut map = HashMap::new(); + map.insert( + "claude-code".to_string(), + vec![ + "env.ANTHROPIC_AUTH_TOKEN".to_string(), + "env.ANTHROPIC_BASE_URL".to_string(), + ], + ); + map.insert( + "codex".to_string(), + vec![ + "auth.json:OPENAI_API_KEY".to_string(), + "model_provider".to_string(), + "model_providers.*.base_url".to_string(), + "model_providers.*.wire_api".to_string(), + ], + ); + map.insert( + "gemini-cli".to_string(), + vec![ + ".env:GEMINI_API_KEY".to_string(), + ".env:GOOGLE_GEMINI_BASE_URL".to_string(), + ], + ); + map +} diff --git a/src-tauri/src/models/tool.rs b/src-tauri/src/models/tool.rs index d55aa70..f420712 100644 --- a/src-tauri/src/models/tool.rs +++ b/src-tauri/src/models/tool.rs @@ -52,6 +52,16 @@ impl Tool { Self::all().into_iter().find(|t| t.id == id) } + /// 获取工具的所有配置文件列表(文件名列表) + pub fn config_files(&self) -> Vec { + match self.id.as_str() { + "claude-code" => vec!["settings.json".to_string()], + "codex" => vec!["config.toml".to_string(), "auth.json".to_string()], + "gemini-cli" => vec!["settings.json".to_string(), ".env".to_string()], + _ => vec![self.config_file.clone()], + } + } + /// Claude Code 定义 pub fn claude_code() -> Tool { let home_dir = dirs::home_dir().expect("无法获取用户主目录"); diff --git a/src-tauri/src/services/config/mod.rs b/src-tauri/src/services/config/mod.rs index 7ce9a81..c58d0cf 100644 --- a/src-tauri/src/services/config/mod.rs +++ b/src-tauri/src/services/config/mod.rs @@ -26,11 +26,8 @@ pub mod watcher; // 重导出类型 pub use types::*; -// 重导出常用 watcher 函数 -pub use watcher::{ - acknowledge_external_change, detect_external_changes, import_external_change, - mark_external_change, ConfigWatcher, NotifyWatcherManager, EXTERNAL_CHANGE_EVENT, -}; +// 重导出 watcher 函数 +pub use watcher::{initialize_snapshots, start_watcher, ExternalConfigChange}; /// 统一的工具配置管理接口 /// diff --git a/src-tauri/src/services/config/types.rs b/src-tauri/src/services/config/types.rs index 8dcc4af..ebd39e6 100644 --- a/src-tauri/src/services/config/types.rs +++ b/src-tauri/src/services/config/types.rs @@ -45,6 +45,18 @@ pub struct ExternalConfigChange { pub checksum: Option, pub detected_at: DateTime, pub dirty: bool, + /// 变更字段路径列表(如 ["env.ANTHROPIC_AUTH_TOKEN", "env.ANTHROPIC_BASE_URL"]) + #[serde(default)] + pub changed_fields: Vec, + /// 是否包含敏感字段 + #[serde(default)] + pub is_sensitive: bool, + /// 新文件内容(用于前端展示和阻止操作) + #[serde(skip_serializing_if = "Option::is_none")] + pub new_content: Option, + /// 旧文件内容(快照) + #[serde(skip_serializing_if = "Option::is_none")] + pub old_content: Option, } /// 导入外部变更的结果 diff --git a/src-tauri/src/services/config/watcher.rs b/src-tauri/src/services/config/watcher.rs index b31148a..42610d1 100644 --- a/src-tauri/src/services/config/watcher.rs +++ b/src-tauri/src/services/config/watcher.rs @@ -1,554 +1,577 @@ -//! 配置文件外部变更检测与监听模块 -//! -//! 提供两种监听机制: -//! - `ConfigWatcher`: 基于轮询的文件监听(跨平台兼容) -//! - `NotifyWatcherManager`: 基于 OS 通知的实时监听(性能更优) - -use super::types::{ExternalConfigChange, ImportExternalChangeResult}; +// 配置文件监听模块 - 重写版本 +// +// 功能: +// 1. 启动时自动保存所有工具的配置快照到 GlobalConfig +// 2. 监听配置文件变更(notify) +// 3. 检测变更并发送事件到前端 +// 4. Block/Allow 操作在 commands 层实现 + +use crate::data::changelogs::ConfigChangeRecord; +use crate::models::config::{ConfigWatchConfig, WatchMode}; use crate::models::Tool; -use crate::services::profile_manager::ProfileManager; use anyhow::{anyhow, Result}; -use chrono::{DateTime, Utc}; -use notify::{ - Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, -}; use serde::Serialize; -use sha2::{Digest, Sha256}; -use std::collections::HashSet; -use std::fs; -use std::path::PathBuf; +use serde_json::Value as JsonValue; +use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc}; use std::thread; use std::time::Duration; -use tauri::Emitter; -use tracing::{debug, warn}; +use tauri::{AppHandle, Emitter}; + +// ========== 导出类型 ========== + +/// 变更类型 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ChangeType { + Modified, + Added, + Deleted, +} -/// 文件变更事件(用于监听器内部) +/// 单个字段的变更 #[derive(Debug, Clone, Serialize)] -pub struct FileChangeEvent { +pub struct FieldChange { + /// 字段路径 + pub path: String, + /// 旧值(删除时为 Some,新增时为 None) + pub old_value: Option, + /// 新值(新增时为 Some,删除时为 None) + pub new_value: Option, + /// 变更类型 + pub change_type: ChangeType, +} + +/// 外部配置变更事件 +#[derive(Debug, Clone, Serialize)] +pub struct ExternalConfigChange { + /// 工具 ID pub tool_id: String, - pub path: PathBuf, - pub checksum: Option, - pub timestamp: DateTime, - pub dirty: bool, - pub fallback_poll: bool, + /// 配置文件路径 + pub path: String, + /// 变更字段列表 + pub changed_fields: Vec, + /// 是否包含敏感字段 + pub is_sensitive: bool, } -/// Tauri 事件名称(外部配置变更通知) -pub const EXTERNAL_CHANGE_EVENT: &str = "external-config-changed"; +// ========== 快照管理 ========== -// ========== 核心函数:配置路径与校验和 ========== +/// 启动时初始化所有工具的配置快照 +pub fn initialize_snapshots() -> Result<()> { + tracing::info!("初始化配置快照..."); -/// 返回工具配置文件列表(包含主配置和附属文件) -pub(crate) fn config_paths(tool: &Tool) -> Vec { - let mut paths = vec![tool.config_dir.join(&tool.config_file)]; - match tool.id.as_str() { - "codex" => { - paths.push(tool.config_dir.join("auth.json")); - } - "gemini-cli" => { - paths.push(tool.config_dir.join(".env")); - } - "claude-code" => { - paths.push(tool.config_dir.join("config.json")); - } - _ => {} - } - paths -} + let tools = vec![Tool::claude_code(), Tool::codex(), Tool::gemini_cli()]; -/// 计算配置文件组合哈希(SHA256) -/// -/// 任一文件变动都会改变校验和,用于检测外部修改 -pub(crate) fn compute_native_checksum(tool: &Tool) -> Option { - let mut paths = config_paths(tool); - paths.sort(); - - let mut hasher = Sha256::new(); - let mut any_exists = false; - for path in paths { - hasher.update(path.to_string_lossy().as_bytes()); - if path.exists() { - any_exists = true; - match fs::read(&path) { - Ok(content) => hasher.update(&content), - Err(_) => return None, - } + for tool in tools { + if let Err(e) = save_snapshot_for_tool(&tool) { + tracing::warn!("保存 {} 配置快照失败: {}", tool.id, e); } else { - hasher.update(b"MISSING"); + tracing::debug!("已保存 {} 配置快照", tool.id); } } - if any_exists { - Some(format!("{:x}", hasher.finalize())) - } else { - None - } + Ok(()) } -// ========== 外部变更检测与管理 ========== +/// 为单个工具保存配置快照 +pub fn save_snapshot_for_tool(tool: &Tool) -> Result<()> { + use crate::data::DataManager; + use std::collections::HashMap; -/// 将外部修改导入为 Profile -/// -/// # Arguments -/// -/// * `tool` - 目标工具 -/// * `profile_name` - Profile 名称 -/// * `as_new` - 是否作为新 Profile(true 时如果已存在则报错) -/// -/// # Errors -/// -/// 当 Profile 名称为空、已存在(as_new=true)或导入失败时返回错误 -pub fn import_external_change( - tool: &Tool, - profile_name: &str, - as_new: bool, -) -> Result { - let target_profile = profile_name.trim(); - if target_profile.is_empty() { - anyhow::bail!("profile 名称不能为空"); - } + let manager = DataManager::new(); + let mut files = HashMap::new(); + + // 读取所有配置文件 + for filename in tool.config_files() { + let config_path = tool.config_dir.join(&filename); + if !config_path.exists() { + tracing::debug!("配置文件不存在,跳过: {}", config_path.display()); + continue; + } - let profile_manager = ProfileManager::new()?; + let content = if filename.ends_with(".json") { + // JSON 文件:直接读取 + manager.json_uncached().read(&config_path)? + } else if filename.ends_with(".toml") { + // TOML 文件:读取 DocumentMut 并转换为 JSON + let doc = manager.toml().read_document(&config_path)?; + toml_to_json(&doc)? + } else if filename.ends_with(".env") || filename == ".env" { + // ENV 文件:读取并转换为 JSON 对象 + let env_map = manager.env().read(&config_path)?; + serde_json::to_value(env_map)? + } else { + tracing::warn!("不支持的配置文件格式: {}", filename); + continue; + }; - // 检查 Profile 是否存在 - let existing = profile_manager.list_profiles(&tool.id)?; - let exists = existing.iter().any(|p| p == target_profile); - if as_new && exists { - anyhow::bail!("profile 已存在: {target_profile}"); + files.insert(filename.clone(), content); } - let checksum_before = compute_native_checksum(tool); + if files.is_empty() { + tracing::warn!("工具 {} 没有可用的配置文件", tool.id); + return Ok(()); + } - // 使用 ProfileManager 的 capture_from_native 方法 - profile_manager.capture_from_native(&tool.id, target_profile)?; + // 保存到独立快照文件 + crate::data::snapshots::save_snapshot_files(&tool.id, files)?; - let checksum = compute_native_checksum(tool); - let replaced = !as_new && exists; + Ok(()) +} - Ok(ImportExternalChangeResult { - profile_name: target_profile.to_string(), - was_new: as_new, - replaced, - before_checksum: checksum_before, - checksum, - }) +/// 将 TOML DocumentMut 转换为 JSON +fn toml_to_json(doc: &toml_edit::DocumentMut) -> Result { + let toml_str = doc.to_string(); + let toml_value: toml::Value = + toml::from_str(&toml_str).map_err(|e| anyhow!("TOML 解析失败: {}", e))?; + Ok(serde_json::to_value(toml_value)?) } -/// 扫描所有工具的原生配置,检测外部修改 +// ========== 差异分析 ========== + +/// 计算两个配置对象的差异 /// -/// # Returns +/// # Arguments /// -/// 返回变更列表,每项包含工具 ID、路径、校验和和脏标记 +/// * `old` - 旧配置 +/// * `new` - 新配置 +/// * `prefix` - 当前路径前缀 /// -/// # Errors +/// # Returns /// -/// 当 ProfileManager 初始化失败或状态访问失败时返回错误 -pub fn detect_external_changes() -> Result> { +/// 返回变更字段列表(包含变更前后值) +fn compute_diff(old: &JsonValue, new: &JsonValue, prefix: &str) -> Vec { let mut changes = Vec::new(); - let profile_manager = ProfileManager::new()?; - for tool in Tool::all() { - // 只检测已经有 active_state 的工具(跳过从未使用过的工具) - let active_opt = profile_manager.get_active_state(&tool.id)?; - if active_opt.is_none() { - continue; + match (old, new) { + (JsonValue::Object(old_map), JsonValue::Object(new_map)) => { + // 检查删除的字段 + for (key, old_val) in old_map.iter() { + if !new_map.contains_key(key) { + let path = if prefix.is_empty() { + key.to_string() + } else { + format!("{}.{}", prefix, key) + }; + changes.push(FieldChange { + path, + old_value: Some(old_val.clone()), + new_value: None, + change_type: ChangeType::Deleted, + }); + } + } + + // 检查新增和修改的字段 + for (key, new_val) in new_map.iter() { + let child_prefix = if prefix.is_empty() { + key.to_string() + } else { + format!("{}.{}", prefix, key) + }; + + if let Some(old_val) = old_map.get(key) { + if old_val != new_val { + // 递归比较子对象 + let mut child_changes = compute_diff(old_val, new_val, &child_prefix); + changes.append(&mut child_changes); + } + } else { + // 新增字段 + changes.push(FieldChange { + path: child_prefix, + old_value: None, + new_value: Some(new_val.clone()), + change_type: ChangeType::Added, + }); + } + } + } + (JsonValue::Array(old_arr), JsonValue::Array(new_arr)) => { + if old_arr != new_arr { + // 数组整体变更 + changes.push(FieldChange { + path: prefix.to_string(), + old_value: Some(old.clone()), + new_value: Some(new.clone()), + change_type: ChangeType::Modified, + }); + } + } + _ => { + // 基本类型或类型变更 + if old != new { + changes.push(FieldChange { + path: prefix.to_string(), + old_value: Some(old.clone()), + new_value: Some(new.clone()), + change_type: ChangeType::Modified, + }); + } } + } + + changes +} + +/// 过滤黑名单字段 +fn filter_blacklist(fields: Vec, blacklist: &[String]) -> Vec { + fields + .into_iter() + .filter(|field| { + for pattern in blacklist { + if pattern.ends_with(".*") { + let prefix = &pattern[..pattern.len() - 2]; + if field.path.starts_with(prefix) { + return false; + } + } else if &field.path == pattern || field.path.starts_with(&format!("{}.", pattern)) + { + return false; + } + } + true + }) + .collect() +} - let current_checksum = compute_native_checksum(&tool); - let active = active_opt.ok_or_else(|| anyhow!("工具 {} 无激活 Profile", tool.id))?; - let last_checksum = active.native_checksum.clone(); - - if last_checksum.as_ref() != current_checksum.as_ref() { - // 标记脏,但保留旧 checksum 以便前端确认后再更新 - profile_manager.mark_active_dirty(&tool.id, true)?; - - changes.push(ExternalConfigChange { - tool_id: tool.id.clone(), - path: tool - .config_dir - .join(&tool.config_file) - .to_string_lossy() - .to_string(), - checksum: current_checksum.clone(), - detected_at: Utc::now(), - dirty: true, - }); - } else if active.dirty { - // 仍在脏状态时保持报告 - changes.push(ExternalConfigChange { - tool_id: tool.id.clone(), - path: tool - .config_dir - .join(&tool.config_file) - .to_string_lossy() - .to_string(), - checksum: current_checksum.clone(), - detected_at: Utc::now(), - dirty: true, - }); +/// 检查是否包含敏感字段 +fn contains_sensitive(fields: &[FieldChange], sensitive: &[String]) -> bool { + for field in fields { + if contains_sensitive_field(&field.path, sensitive) { + return true; } } - Ok(changes) + false } -/// 标记外部修改(用于事件监听场景) -/// -/// # Arguments -/// -/// * `tool` - 目标工具 -/// * `path` - 发生变更的文件路径 -/// * `checksum` - 新的校验和 -/// -/// # Returns -/// -/// 返回变更事件,包含脏标记(仅当校验和变化时为 true) -pub fn mark_external_change( - tool: &Tool, - path: PathBuf, - checksum: Option, -) -> Result { - let profile_manager = ProfileManager::new()?; - let active_opt = profile_manager.get_active_state(&tool.id)?; +// ========== 变更检测 ========== - let last_checksum = active_opt.as_ref().and_then(|a| a.native_checksum.clone()); +/// 检测单个工具的配置变更 +fn detect_tool_change( + tool: &Tool, + watch_config: &ConfigWatchConfig, +) -> Result> { + use crate::data::DataManager; + + // 获取快照 + let snapshot = match crate::data::snapshots::get_snapshot(&tool.id)? { + Some(s) => s, + None => { + // 首次检测:自动保存当前状态作为快照 + tracing::debug!("首次检测配置文件,自动保存快照: {}", tool.id); + save_snapshot_for_tool(tool)?; + return Ok(None); + } + }; - // 若与当前记录的 checksum 一致,则视为内部写入,保持非脏状态 - let checksum_changed = last_checksum.as_ref() != checksum.as_ref(); + // 读取所有当前配置文件 + let manager = DataManager::new(); + let mut current_files = std::collections::HashMap::new(); - // 更新 checksum 和 dirty 状态 - profile_manager.update_active_sync_state(&tool.id, checksum.clone(), checksum_changed)?; + for filename in tool.config_files() { + let config_path = tool.config_dir.join(&filename); + if !config_path.exists() { + continue; + } - Ok(ExternalConfigChange { - tool_id: tool.id.clone(), - path: path.to_string_lossy().to_string(), - checksum, - detected_at: Utc::now(), - dirty: checksum_changed, - }) -} + let content = if filename.ends_with(".json") { + manager.json_uncached().read(&config_path)? + } else if filename.ends_with(".toml") { + let doc = manager.toml().read_document(&config_path)?; + toml_to_json(&doc)? + } else if filename.ends_with(".env") || filename == ".env" { + let env_map = manager.env().read(&config_path)?; + serde_json::to_value(env_map)? + } else { + continue; + }; -/// 确认/清除外部修改状态,刷新校验和 -/// -/// # Arguments -/// -/// * `tool` - 目标工具 -/// -/// # Errors -/// -/// 当 ProfileManager 操作失败时返回错误 -pub fn acknowledge_external_change(tool: &Tool) -> Result<()> { - let current_checksum = compute_native_checksum(tool); + current_files.insert(filename.clone(), content); + } - let profile_manager = ProfileManager::new()?; - profile_manager.update_active_sync_state(&tool.id, current_checksum, false)?; + // 比较所有文件的变更 + let mut all_changes = Vec::new(); + + for (filename, new_content) in ¤t_files { + let old_content = snapshot.files.get(filename); + if let Some(old) = old_content { + let file_changes = compute_diff(old, new_content, ""); + // 为每个变更字段添加文件前缀(如果不是主配置文件) + for mut change in file_changes { + if filename != &tool.config_file { + change.path = format!("{}:{}", filename, change.path); + } + all_changes.push(change); + } + } else { + // 新增文件 + tracing::debug!("检测到新增配置文件: {}", filename); + } + } - Ok(()) -} + // 检测删除的文件 + for filename in snapshot.files.keys() { + if !current_files.contains_key(filename) { + tracing::debug!("检测到删除的配置文件: {}", filename); + } + } -// ========== 文件监听器:轮询模式 ========== + if all_changes.is_empty() { + return Ok(None); + } -/// 基于轮询的配置文件监听器 -/// -/// 通过定期检查文件校验和来检测变更,兼容性好但资源占用较高 -pub struct ConfigWatcher { - stop: Arc, - handle: Option>, -} + // 应用黑名单过滤 + let mut changed_fields = all_changes; + if let Some(blacklist) = watch_config.blacklist.get(&tool.id) { + changed_fields = filter_blacklist(changed_fields, blacklist); + } -impl ConfigWatcher { - /// 轮询监听单个文件变更 - /// - /// # Arguments - /// - /// * `tool_id` - 工具 ID - /// * `path` - 文件路径 - /// * `poll_interval` - 轮询间隔 - /// * `mark_dirty` - 是否标记为脏状态 - /// - /// # Returns - /// - /// 返回监听器实例和事件接收器 - pub fn watch_file_polling( - tool_id: impl Into, - path: PathBuf, - poll_interval: Duration, - mark_dirty: bool, - ) -> Result<(Self, mpsc::Receiver)> { - use crate::utils::file_helpers::file_checksum; - - let tool_id = tool_id.into(); - let mut last_checksum = file_checksum(&path).ok(); - let stop = Arc::new(AtomicBool::new(false)); - let stop_token = stop.clone(); - let (tx, rx) = mpsc::channel(); - let watch_path = path.clone(); - - let handle = thread::spawn(move || { - while !stop_token.load(Ordering::Relaxed) { - let checksum = file_checksum(&watch_path).ok(); - if checksum.is_some() && checksum != last_checksum { - // 轻微防抖,避免写入过程中的空文件/瞬时内容导致重复事件 - thread::sleep(Duration::from_millis(10)); - let stable_checksum = file_checksum(&watch_path).ok().or(checksum.clone()); - - if stable_checksum.is_some() && stable_checksum != last_checksum { - last_checksum = stable_checksum.clone(); - let change = FileChangeEvent { - tool_id: tool_id.clone(), - path: watch_path.clone(), - checksum: stable_checksum, - timestamp: Utc::now(), - dirty: mark_dirty, - fallback_poll: true, - }; - let _ = tx.send(change); - } - } - thread::sleep(poll_interval); + // 根据监听模式过滤 + match watch_config.mode { + WatchMode::Default => { + // 默认模式:仅保留敏感字段变更 + if let Some(sensitive) = watch_config.sensitive_fields.get(&tool.id) { + changed_fields.retain(|field| contains_sensitive_field(&field.path, sensitive)); + } else { + // 没有敏感字段定义,清空变更列表 + changed_fields.clear(); } - }); + } + WatchMode::Full => { + // 全量模式:保留所有非黑名单字段 + } + } - Ok(( - Self { - stop, - handle: Some(handle), - }, - rx, - )) + if changed_fields.is_empty() { + return Ok(None); } + + // 检查是否包含敏感字段 + let is_sensitive = if let Some(sensitive) = watch_config.sensitive_fields.get(&tool.id) { + contains_sensitive(&changed_fields, sensitive) + } else { + false + }; + + let config_path = tool.config_dir.join(&tool.config_file); + Ok(Some(ExternalConfigChange { + tool_id: tool.id.clone(), + path: config_path.to_string_lossy().to_string(), + changed_fields, + is_sensitive, + })) } -impl Drop for ConfigWatcher { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); +/// 检查字段路径是否匹配敏感字段模式(支持文件前缀) +fn contains_sensitive_field(field_path: &str, patterns: &[String]) -> bool { + for pattern in patterns { + // 支持带文件前缀的模式(如 auth.json:OPENAI_API_KEY) + let matches = if pattern.ends_with(".*") { + let prefix = &pattern[..pattern.len() - 2]; + field_path.starts_with(prefix) + } else if pattern.contains(':') { + // 带文件前缀的精确匹配或子字段匹配 + field_path == pattern || field_path.starts_with(&format!("{}.", pattern)) + } else { + // 不带文件前缀的模式:匹配主配置文件中的字段 + field_path == pattern || field_path.starts_with(&format!("{}.", pattern)) + }; + + if matches { + return true; } } + false } -// ========== 文件监听器:OS 通知模式 ========== +// ========== 文件监听 ========== -/// 基于 notify 的实时配置文件监听管理器 -/// -/// 使用操作系统级文件通知,性能优异但依赖平台支持 -pub struct NotifyWatcherManager { - _watchers: Vec, +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::sync::Mutex; + +/// 全局 Watcher 实例 +static WATCHER_HANDLE: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(None)); + +/// Watcher 句柄 +struct WatcherHandle { + _watcher: RecommendedWatcher, + stop_signal: Arc, } -impl NotifyWatcherManager { - /// 监听单个配置文件 - fn watch_single( - tool: Tool, - path: PathBuf, - app: tauri::AppHandle, - ) -> Result { - let path_for_cb = path.clone(); - let tool_for_state = tool.clone(); - let mut last_checksum = compute_native_checksum(&tool_for_state); - let mut watcher = RecommendedWatcher::new( - move |res: Result| { - if let Ok(event) = res { - match event.kind { - EventKind::Modify(_) | EventKind::Create(_) => { - let checksum = compute_native_checksum(&tool_for_state); - // 去重:相同 checksum 不重复触发 - if checksum == last_checksum { - return; - } - last_checksum = checksum.clone(); - - match mark_external_change( - &tool_for_state, - path_for_cb.clone(), - checksum, - ) { - Ok(change) => { - // 仅在确实变脏时通知前端,避免内部写入误报 - if change.dirty { - debug!( - tool = %change.tool_id, - path = %change.path, - checksum = ?change.checksum, - "检测到配置文件改动(notify watcher)" - ); - let _ = app.emit(EXTERNAL_CHANGE_EVENT, change); - } - } - Err(err) => { - warn!( - tool = %tool_for_state.id, - path = ?path_for_cb, - error = ?err, - "标记外部变更失败" - ); - } - } +/// 启动配置文件监听 +pub fn start_watcher(app_handle: AppHandle) -> Result<()> { + // 读取配置判断是否启用 + let global_config = crate::utils::config::read_global_config() + .map_err(|e| anyhow!(e))? + .ok_or_else(|| anyhow!("全局配置文件不存在"))?; + + if !global_config.config_watch.enabled { + tracing::info!("配置守护已禁用,跳过启动 watcher"); + return Ok(()); + } + + let scan_interval = global_config.config_watch.scan_interval; + tracing::info!("启动配置守护,扫描间隔: {}秒", scan_interval); + + // 停止旧的 watcher + stop_watcher()?; + + let (tx, rx) = mpsc::channel(); + let running = Arc::new(AtomicBool::new(true)); + + // 创建 notify watcher + let tools = vec![Tool::claude_code(), Tool::codex(), Tool::gemini_cli()]; + + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + match event.kind { + EventKind::Modify(_) | EventKind::Create(_) => { + if let Some(path) = event.paths.first() { + let _ = tx.send(path.clone()); } - _ => {} } + _ => {} } - }, - NotifyConfig::default(), - )?; - - watcher.watch(&path, RecursiveMode::NonRecursive)?; - Ok(watcher) + } + }, + notify::Config::default().with_poll_interval(Duration::from_secs(scan_interval)), + )?; + + // 监听所有工具的配置目录 + for tool in &tools { + if tool.config_dir.exists() { + watcher.watch(&tool.config_dir, RecursiveMode::NonRecursive)?; + tracing::debug!("开始监听配置目录: {}", tool.config_dir.display()); + } } - /// 为所有已存在的配置文件启动监听器 - /// - /// # Arguments - /// - /// * `app` - Tauri AppHandle,用于发送事件到前端 - /// - /// # Returns - /// - /// 返回管理器实例,持有所有监听器 - pub fn start_all(app: tauri::AppHandle) -> Result { - let mut watchers = Vec::new(); - for tool in Tool::all() { - let mut seen = HashSet::new(); - for path in config_paths(&tool) { - if !seen.insert(path.clone()) { - continue; + // 后台线程处理变更 + let running_clone = running.clone(); + thread::spawn(move || { + let mut last_check = std::collections::HashMap::new(); + + while running_clone.load(Ordering::Relaxed) { + if let Ok(path) = rx.recv_timeout(Duration::from_millis(500)) { + // 防抖:同一路径 500ms 内只处理一次 + let now = std::time::Instant::now(); + if let Some(last) = last_check.get(&path) { + if now.duration_since(*last) < Duration::from_millis(500) { + continue; + } } - if !path.exists() { - warn!( - tool = %tool.id, - path = ?path, - "配置文件不存在,跳过通知 watcher(将依赖轮询/手动刷新)" - ); - continue; + last_check.insert(path.clone(), now); + + // 检测变更 + if let Err(e) = handle_file_change(&path, &app_handle) { + tracing::error!("处理配置变更失败: {}", e); } - let watcher = Self::watch_single(tool.clone(), path, app.clone())?; - watchers.push(watcher); } } - debug!(count = watchers.len(), "通知 watcher 启动完成"); - Ok(Self { - _watchers: watchers, - }) - } + }); + + // 保存句柄 + let handle = WatcherHandle { + _watcher: watcher, + stop_signal: running, + }; + *WATCHER_HANDLE.lock().unwrap() = Some(handle); + + Ok(()) } -// ========== 测试 ========== - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::time::SystemTime; - - #[test] - fn watcher_emits_on_change_and_filters_duplicate_checksum() -> Result<()> { - let dir = std::env::temp_dir().join(format!( - "duckcoding_watch_test_{}", - SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() - )); - if dir.exists() { - fs::remove_dir_all(&dir)?; - } - fs::create_dir_all(&dir)?; - let path = dir.join("settings.json"); - fs::write(&path, r#"{"env":{"KEY":"A"}}"#)?; - - let (_watcher, rx) = ConfigWatcher::watch_file_polling( - "claude-code", - path.clone(), - Duration::from_millis(50), - true, - )?; - - // 改变内容,期望收到事件 - fs::write(&path, r#"{"env":{"KEY":"B"}}"#)?; - let change = rx - .recv_timeout(Duration::from_secs(3)) - .expect("should receive change event"); - assert_eq!(change.tool_id, "claude-code"); - assert_eq!(change.path, path); - assert!(change.checksum.is_some()); - - // 再写入相同内容,不应再次触发 - fs::write(&path, r#"{"env":{"KEY":"B"}}"#)?; - assert!(rx.recv_timeout(Duration::from_millis(300)).is_err()); - - let _ = fs::remove_dir_all(&dir); - Ok(()) +/// 停止配置文件监听 +pub fn stop_watcher() -> Result<()> { + let mut handle = WATCHER_HANDLE.lock().unwrap(); + if let Some(h) = handle.take() { + h.stop_signal.store(false, Ordering::Relaxed); + tracing::info!("配置守护已停止"); } + Ok(()) +} - #[test] - fn watcher_respects_mark_dirty_flag() -> Result<()> { - let dir = std::env::temp_dir().join(format!( - "duckcoding_watch_test_mark_dirty_{}", - SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() - )); - if dir.exists() { - fs::remove_dir_all(&dir)?; - } - fs::create_dir_all(&dir)?; - let path = dir.join("settings.json"); - fs::write(&path, r#"{"env":{"KEY":"X"}}"#)?; - - // mark_dirty = false,应当仍能收到事件,但 dirty 为 false - let (_watcher, rx) = ConfigWatcher::watch_file_polling( - "codex", - path.clone(), - Duration::from_millis(30), - false, - )?; - - fs::write(&path, r#"{"env":{"KEY":"Y"}}"#)?; - let change = rx - .recv_timeout(Duration::from_secs(3)) - .expect("should receive change event"); - - assert_eq!(change.tool_id, "codex"); - assert_eq!(change.path, path); - assert!(change.checksum.is_some()); - assert!(!change.dirty, "dirty flag should respect mark_dirty=false"); - assert!( - change.fallback_poll, - "polling watcher should mark fallback_poll" - ); - - let _ = fs::remove_dir_all(&dir); - Ok(()) - } +/// 处理单个文件变更 +fn handle_file_change(path: &Path, app_handle: &AppHandle) -> Result<()> { + // 读取全局配置 + let global_config = crate::utils::config::read_global_config() + .map_err(|e| anyhow!(e))? + .ok_or_else(|| anyhow!("全局配置文件不存在"))?; - #[test] - #[ignore = "需要使用 ProfileManager API 重写"] - fn mark_external_change_clears_dirty_when_checksum_unchanged() -> Result<()> { - // TODO: 需要使用 ProfileManager API 重写此测试 - unimplemented!("需要使用 ProfileManager API 重写此测试") - } + let watch_config = &global_config.config_watch; - #[test] - #[ignore = "需要使用 ProfileManager API 重写"] - fn mark_external_change_preserves_last_synced_at() -> Result<()> { - // TODO: 需要使用 ProfileManager API 重写此测试 - unimplemented!("需要使用 ProfileManager API 重写此测试") - } + // 找到对应的工具 + let tools = vec![Tool::claude_code(), Tool::codex(), Tool::gemini_cli()]; - #[test] - #[ignore = "需要使用 ProfileManager API 重写"] - fn detect_and_ack_external_change_updates_state() -> Result<()> { - // TODO: 需要使用 ProfileManager API 重写此测试 - unimplemented!("需要使用 ProfileManager API 重写此测试") - } + for tool in tools { + // 检查是否是该工具的任一配置文件 + let is_tool_config = tool.config_files().iter().any(|filename| { + let config_path = tool.config_dir.join(filename); + config_path == path + }); + + if is_tool_config { + // 检测变更 + if let Some(change) = detect_tool_change(&tool, watch_config)? { + tracing::info!( + "检测到配置变更: {} ({} 个字段)", + change.tool_id, + change.changed_fields.len() + ); + + // 记录到变更日志 + use crate::data::changelogs::ConfigChangeRecord; + use std::collections::HashMap; + + let mut before_values = HashMap::new(); + let mut after_values = HashMap::new(); + let changed_field_paths: Vec = change + .changed_fields + .iter() + .map(|f| { + if let Some(old) = &f.old_value { + before_values.insert(f.path.clone(), old.clone()); + } + if let Some(new) = &f.new_value { + after_values.insert(f.path.clone(), new.clone()); + } + f.path.clone() + }) + .collect(); + + let record = ConfigChangeRecord { + tool_id: change.tool_id.clone(), + timestamp: chrono::Utc::now(), + changed_fields: changed_field_paths, + is_sensitive: change.is_sensitive, + before_values, + after_values, + action: None, // 用户尚未操作 + }; + + if let Err(e) = save_change_record(record) { + tracing::error!("保存变更日志失败: {}", e); + } - #[test] - #[ignore = "需要使用 ProfileManager API 重写"] - fn delete_profile_marks_active_dirty_when_matching() -> Result<()> { - // TODO: 需要使用 ProfileManager API 重写此测试 - unimplemented!("需要使用 ProfileManager API 重写此测试") + // 发送事件到前端 + app_handle.emit("external-config-changed", change)?; + } + break; + } } + + Ok(()) +} + +/// 保存变更记录到日志 +fn save_change_record(record: ConfigChangeRecord) -> Result<()> { + use crate::data::changelogs::ChangeLogStore; + + let mut store = ChangeLogStore::load()?; + store.add_record(record); + store.save()?; + Ok(()) } diff --git a/src-tauri/src/services/migration_manager/manager.rs b/src-tauri/src/services/migration_manager/manager.rs index 43da80a..14f2918 100644 --- a/src-tauri/src/services/migration_manager/manager.rs +++ b/src-tauri/src/services/migration_manager/manager.rs @@ -191,6 +191,7 @@ impl MigrationManager { external_poll_interval_ms: 5000, single_instance_enabled: true, startup_enabled: false, + config_watch: crate::models::config::ConfigWatchConfig::default(), }); config.version = Some(new_version.to_string()); diff --git a/src-tauri/src/services/migration_manager/migrations/profile_v2.rs b/src-tauri/src/services/migration_manager/migrations/profile_v2.rs index 7f66ddf..b74e9f8 100644 --- a/src-tauri/src/services/migration_manager/migrations/profile_v2.rs +++ b/src-tauri/src/services/migration_manager/migrations/profile_v2.rs @@ -451,6 +451,8 @@ impl ProfileV2Migration { switched_at: old_state.last_synced_at.unwrap_or_else(Utc::now), native_checksum: old_state.native_checksum, dirty: old_state.dirty, + native_snapshot: None, + last_synced_at: old_state.last_synced_at.unwrap_or_else(Utc::now), })) } diff --git a/src-tauri/src/services/profile_manager/manager.rs b/src-tauri/src/services/profile_manager/manager.rs index 9f1c376..0beafe3 100644 --- a/src-tauri/src/services/profile_manager/manager.rs +++ b/src-tauri/src/services/profile_manager/manager.rs @@ -411,6 +411,17 @@ impl ProfileManager { // 应用到原生配置文件 self.apply_to_native(tool_id, profile_name)?; + // 读取应用后的配置并保存快照 + let tool = crate::models::Tool::by_id(tool_id) + .ok_or_else(|| anyhow!("未找到工具: {}", tool_id))?; + let config_path = tool.config_dir.join(&tool.config_file); + if config_path.exists() { + let manager = crate::data::DataManager::new(); + let snapshot = manager.json_uncached().read(&config_path)?; + self.save_native_snapshot(tool_id, snapshot)?; + tracing::debug!("已保存 Profile 快照: {} / {}", tool_id, profile_name); + } + Ok(()) } @@ -690,6 +701,47 @@ impl ProfileManager { _ => Err(anyhow!("不支持的工具 ID: {}", tool_id)), } } + + // ==================== 快照管理 ==================== + + /// 保存原生配置快照到 ActiveProfile + /// + /// # Arguments + /// + /// * `tool_id` - 工具 ID + /// * `snapshot` - 配置快照(JSON Value) + /// + /// # Errors + /// + /// 当保存失败时返回错误 + pub fn save_native_snapshot(&self, tool_id: &str, snapshot: serde_json::Value) -> Result<()> { + let mut active_store = self.load_active_store()?; + + // 获取激活的 Profile 并更新快照 + if let Some(active) = active_store.get_active_mut(tool_id) { + active.native_snapshot = Some(snapshot); + active.last_synced_at = chrono::Utc::now(); + self.save_active_store(&active_store) + } else { + Err(anyhow!("工具 {} 没有激活的 Profile", tool_id)) + } + } + + /// 获取原生配置快照 + /// + /// # Arguments + /// + /// * `tool_id` - 工具 ID + /// + /// # Returns + /// + /// 返回配置快照,如果不存在返回 None + pub fn get_native_snapshot(&self, tool_id: &str) -> Result> { + let active_store = self.load_active_store()?; + Ok(active_store + .get_active(tool_id) + .and_then(|a| a.native_snapshot.clone())) + } } impl Default for ProfileManager { diff --git a/src-tauri/src/services/profile_manager/types.rs b/src-tauri/src/services/profile_manager/types.rs index 31389f7..9d85b3c 100644 --- a/src-tauri/src/services/profile_manager/types.rs +++ b/src-tauri/src/services/profile_manager/types.rs @@ -203,6 +203,8 @@ impl ActiveStore { switched_at: Utc::now(), native_checksum: None, dirty: false, + native_snapshot: None, + last_synced_at: Utc::now(), }; match tool_id { @@ -240,6 +242,12 @@ pub struct ActiveProfile { pub native_checksum: Option, #[serde(default)] pub dirty: bool, + /// 原生配置文件的完整快照(用于阻止变更时恢复) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_snapshot: Option, + /// 最后同步时间 + #[serde(default)] + pub last_synced_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/services/proxy/proxy_service.rs b/src-tauri/src/services/proxy/proxy_service.rs index d5584ae..8ec04c8 100644 --- a/src-tauri/src/services/proxy/proxy_service.rs +++ b/src-tauri/src/services/proxy/proxy_service.rs @@ -230,6 +230,7 @@ mod tests { external_poll_interval_ms: 5000, single_instance_enabled: true, startup_enabled: false, + config_watch: crate::models::config::ConfigWatchConfig::default(), }; let url = ProxyService::build_proxy_url(&config); @@ -259,6 +260,7 @@ mod tests { external_poll_interval_ms: 5000, single_instance_enabled: true, startup_enabled: false, + config_watch: crate::models::config::ConfigWatchConfig::default(), }; let url = ProxyService::build_proxy_url(&config); @@ -291,6 +293,7 @@ mod tests { external_poll_interval_ms: 5000, single_instance_enabled: true, startup_enabled: false, + config_watch: crate::models::config::ConfigWatchConfig::default(), }; let url = ProxyService::build_proxy_url(&config); diff --git a/src-tauri/src/setup/initialization.rs b/src-tauri/src/setup/initialization.rs index 1fa0e92..3b41067 100644 --- a/src-tauri/src/setup/initialization.rs +++ b/src-tauri/src/setup/initialization.rs @@ -120,6 +120,27 @@ async fn run_migrations() -> Result<(), Box> { Ok(()) } +/// 标记未处理的配置变更日志为已过期 +fn mark_expired_change_logs() -> Result<(), Box> { + use duckcoding::data::changelogs::ChangeLogStore; + + match ChangeLogStore::load() { + Ok(mut store) => { + store.mark_pending_as_expired(); + if let Err(e) = store.save() { + tracing::warn!(error = ?e, "保存过期日志失败"); + } else { + tracing::debug!("已标记未处理的配置变更日志为已过期"); + } + } + Err(e) => { + tracing::warn!(error = ?e, "加载配置变更日志失败"); + } + } + + Ok(()) +} + /// 自动启动配置的代理 async fn auto_start_proxies( proxy_manager: &Arc, @@ -130,7 +151,7 @@ async fn auto_start_proxies( /// 执行所有启动初始化任务 /// -/// 按顺序执行:日志 → Profile → 迁移 → 工具注册表 → 代理管理器 +/// 按顺序执行:日志 → Profile → 迁移 → 标记过期日志 → 工具注册表 → 代理管理器 pub async fn initialize_app() -> Result> { // 1. 初始化日志 init_logging()?; @@ -143,15 +164,20 @@ pub async fn initialize_app() -> Result { try { @@ -385,6 +397,15 @@ function App() { )} {activeTab === 'provider-management' && } {activeTab === 'help' && } + {activeTab === 'about' && ( + { + setUpdateInfo(null); + setIsUpdateDialogOpen(true); + }} + /> + )} {/* 更新对话框 */} @@ -411,6 +432,14 @@ function App() { } /> + {/* 配置变更通知对话框 */} + + {/* Toast 通知 */} diff --git a/src/components/dialogs/ChangeLogDialog.tsx b/src/components/dialogs/ChangeLogDialog.tsx new file mode 100644 index 0000000..7817618 --- /dev/null +++ b/src/components/dialogs/ChangeLogDialog.tsx @@ -0,0 +1,273 @@ +/** + * 配置变更历史对话框 - 支持分页 + */ +import { useState, useEffect, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { + Loader2, + AlertCircle, + Clock, + Settings, + Trash2, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { getChangeLogsPage, clearChangeLogs } from '@/lib/tauri-commands'; +import type { ConfigChangeRecord } from '@/types/config-watch'; +import { TOOL_DISPLAY_NAMES, ACTION_TYPE_LABELS } from '@/types/config-watch'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface ChangeLogDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const PAGE_SIZE = 10; + +export function ChangeLogDialog({ open, onOpenChange }: ChangeLogDialogProps) { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [clearDialogOpen, setClearDialogOpen] = useState(false); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + const loadLogs = useCallback( + async (currentPage: number) => { + try { + setLoading(true); + const [records, totalCount] = await getChangeLogsPage(currentPage, PAGE_SIZE); + setLogs(records); + setTotal(totalCount); + } catch (error) { + toast({ + title: '加载失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }, + [toast], + ); + + useEffect(() => { + if (open) { + setPage(0); + loadLogs(0); + } + }, [open, loadLogs]); + + const handleClearLogs = async () => { + try { + await clearChangeLogs(); + setLogs([]); + setTotal(0); + setPage(0); + setClearDialogOpen(false); + toast({ + title: '清除成功', + description: '所有变更历史已清除', + }); + } catch (error) { + toast({ + title: '清除失败', + description: String(error), + variant: 'destructive', + }); + } + }; + + const handlePageChange = (newPage: number) => { + setPage(newPage); + loadLogs(newPage); + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }; + + const getActionBadge = (action?: string) => { + if (!action) { + return 待处理; + } + + const label = ACTION_TYPE_LABELS[action as keyof typeof ACTION_TYPE_LABELS] || action; + + switch (action) { + case 'allow': + return {label}; + case 'block': + return {label}; + case 'superseded': + return {label}; + case 'expired': + return ( + + {label} + + ); + default: + return {label}; + } + }; + + return ( + <> + + + + + + 配置变更历史 + + + 查看所有检测到的配置文件变更记录(最多保留 100 条) + + + + {loading ? ( +
+ +
+ ) : total === 0 ? ( +
+ +

暂无变更记录

+
+ ) : ( +
+
+

+ 共 {total} 条记录,第 {page + 1} / {totalPages} 页 +

+ +
+ + +
+ {logs.map((log, index) => ( + + +
+
+ + + {TOOL_DISPLAY_NAMES[log.tool_id] || log.tool_id} + + {log.is_sensitive && ( + + 敏感变更 + + )} + {getActionBadge(log.action)} +
+
+ + {formatTimestamp(log.timestamp)} +
+
+
+ +
+ 变更字段: +
+ {log.changed_fields.map((field, idx) => ( + + {field} + + ))} +
+
+
+
+ ))} +
+
+ + {/* 分页控件 */} +
+ + + {page + 1} / {totalPages} + + +
+
+ )} +
+
+ + {/* 清除确认对话框 */} + + + + 确认清除 + + 此操作将清除所有配置变更历史记录,且无法恢复。您确定要继续吗? + + + + 取消 + 确认清除 + + + + + ); +} diff --git a/src/components/dialogs/ConfigChangeDialog.tsx b/src/components/dialogs/ConfigChangeDialog.tsx new file mode 100644 index 0000000..79f924c --- /dev/null +++ b/src/components/dialogs/ConfigChangeDialog.tsx @@ -0,0 +1,253 @@ +/** + * 配置变更通知对话框 + */ +import { useState } from 'react'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { AlertTriangle, Info } from 'lucide-react'; +import { blockExternalChange, allowExternalChange } from '@/lib/tauri-commands'; +import { useToast } from '@/hooks/use-toast'; +import type { ExternalConfigChange } from '@/types/config-watch'; +import { TOOL_DISPLAY_NAMES, CHANGE_TYPE_LABELS } from '@/types/config-watch'; + +interface ConfigChangeDialogProps { + /** 是否打开 */ + open: boolean; + /** 关闭回调 */ + onClose: () => void; + /** 变更信息 */ + change: ExternalConfigChange | null; + /** 队列中剩余的变更数量 */ + queueLength?: number; +} + +export function ConfigChangeDialog({ + open, + onClose, + change, + queueLength = 0, +}: ConfigChangeDialogProps) { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + + if (!change) return null; + + const toolName = TOOL_DISPLAY_NAMES[change.tool_id] || change.tool_id; + + /** + * 格式化 JSON 值用于显示 + */ + const formatValue = (value: any): string => { + if (value === undefined || value === null) { + return 'null'; + } + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value, null, 2); + }; + + /** + * 获取变更类型的样式类 + */ + const getChangeTypeColor = (changeType: string): string => { + switch (changeType) { + case 'added': + return 'text-green-600 border-green-300'; + case 'deleted': + return 'text-red-600 border-red-300'; + default: + return 'text-blue-600 border-blue-300'; + } + }; + + const handleBlock = async () => { + setLoading(true); + try { + await blockExternalChange(change.tool_id); + toast({ + title: '已阻止变更', + description: `已恢复 ${toolName} 的配置到上次保存的状态`, + }); + onClose(); + } catch (error) { + toast({ + title: '阻止失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const handleAllow = async () => { + setLoading(true); + try { + await allowExternalChange(change.tool_id); + toast({ + title: '已允许变更', + description: `已更新 ${toolName} 的配置快照`, + }); + onClose(); + } catch (error) { + toast({ + title: '允许失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {change.is_sensitive ? ( + + ) : ( + + )} + 检测到配置文件变更 + {queueLength > 0 && ( + + 还有 {queueLength} 个待处理 + + )} + + +
+ {/* 工具和文件信息 */} +
+
+ 工具: + {toolName} + {change.is_sensitive && ( + + 敏感变更 + + )} +
+
+ 文件路径: +
{change.path}
+
+
+ + {/* 变更详情 */} + {change.changed_fields.length > 0 && ( +
+ + 变更详情 ({change.changed_fields.length} 个字段): + + +
+ {change.changed_fields.map((field, index) => ( + + +
+ {field.path} + + {CHANGE_TYPE_LABELS[field.change_type]} + +
+
+ + {/* 修改类型:显示 Before 和 After */} + {field.change_type === 'modified' && ( +
+
+ +
+                                    {formatValue(field.old_value)}
+                                  
+
+
+ +
+                                    {formatValue(field.new_value)}
+                                  
+
+
+ )} + + {/* 新增类型:只显示 After */} + {field.change_type === 'added' && ( +
+ +
+                                  {formatValue(field.new_value)}
+                                
+
+ )} + + {/* 删除类型:只显示 Before */} + {field.change_type === 'deleted' && ( +
+ +
+                                  {formatValue(field.old_value)}
+                                
+
+ )} +
+
+ ))} +
+
+
+ )} + + {/* 操作说明 */} +
+
+
+ 阻止: + + 将配置文件恢复到 DuckCoding 记录的上次状态 + +
+
+ 允许: + + 接受外部修改,并更新 DuckCoding 的配置快照 + +
+
+
+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/components/dialogs/FieldManagementDialog.tsx b/src/components/dialogs/FieldManagementDialog.tsx new file mode 100644 index 0000000..552e60f --- /dev/null +++ b/src/components/dialogs/FieldManagementDialog.tsx @@ -0,0 +1,319 @@ +/** + * 字段管理对话框 + */ +import { useState, useEffect, KeyboardEvent } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Label } from '@/components/ui/label'; +import { X, Plus, RotateCcw } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { + updateWatchConfig, + getDefaultSensitiveFields, + getDefaultBlacklist, +} from '@/lib/tauri-commands'; +import type { ConfigWatchConfig } from '@/types/config-watch'; +import { TOOL_DISPLAY_NAMES } from '@/types/config-watch'; + +interface FieldManagementDialogProps { + /** 是否打开 */ + open: boolean; + /** 关闭回调 */ + onOpenChange: (open: boolean) => void; + /** 当前配置 */ + config: ConfigWatchConfig; + /** 配置更新回调 */ + onConfigUpdate: (config: ConfigWatchConfig) => void; +} + +export function FieldManagementDialog({ + open, + onOpenChange, + config, + onConfigUpdate, +}: FieldManagementDialogProps) { + const { toast } = useToast(); + const [sensitiveFields, setSensitiveFields] = useState>({}); + const [blacklist, setBlacklist] = useState>({}); + const [newFieldInputs, setNewFieldInputs] = useState>({}); + const [saving, setSaving] = useState(false); + + // 初始化本地状态 + useEffect(() => { + if (open && config) { + setSensitiveFields({ ...config.sensitive_fields }); + setBlacklist({ ...config.blacklist }); + setNewFieldInputs({}); + } + }, [open, config]); + + const handleAddField = ( + toolId: string, + isSensitive: boolean, + e?: KeyboardEvent, + ) => { + if (e && e.key !== 'Enter') return; + + const input = newFieldInputs[`${isSensitive ? 'sensitive' : 'blacklist'}-${toolId}`] || ''; + const trimmed = input.trim(); + + if (!trimmed) return; + + if (isSensitive) { + const current = sensitiveFields[toolId] || []; + if (current.includes(trimmed)) { + toast({ + title: '字段已存在', + description: `敏感字段列表中已包含 "${trimmed}"`, + variant: 'destructive', + }); + return; + } + setSensitiveFields({ + ...sensitiveFields, + [toolId]: [...current, trimmed], + }); + } else { + const current = blacklist[toolId] || []; + if (current.includes(trimmed)) { + toast({ + title: '字段已存在', + description: `黑名单字段列表中已包含 "${trimmed}"`, + variant: 'destructive', + }); + return; + } + setBlacklist({ + ...blacklist, + [toolId]: [...current, trimmed], + }); + } + + // 清空输入框 + setNewFieldInputs({ + ...newFieldInputs, + [`${isSensitive ? 'sensitive' : 'blacklist'}-${toolId}`]: '', + }); + }; + + const handleRemoveField = (toolId: string, field: string, isSensitive: boolean) => { + if (isSensitive) { + const current = sensitiveFields[toolId] || []; + setSensitiveFields({ + ...sensitiveFields, + [toolId]: current.filter((f) => f !== field), + }); + } else { + const current = blacklist[toolId] || []; + setBlacklist({ + ...blacklist, + [toolId]: current.filter((f) => f !== field), + }); + } + }; + + const handleResetToDefault = async (toolId: string, isSensitive: boolean) => { + try { + if (isSensitive) { + // 从后端获取默认敏感字段 + const defaults = await getDefaultSensitiveFields(); + setSensitiveFields({ + ...sensitiveFields, + [toolId]: [...(defaults[toolId] || [])], + }); + toast({ + title: '已重置', + description: `已将 ${TOOL_DISPLAY_NAMES[toolId]} 的敏感字段重置为默认`, + }); + } else { + // 从后端获取默认黑名单 + const defaults = await getDefaultBlacklist(); + setBlacklist({ + ...blacklist, + [toolId]: [...(defaults[toolId] || [])], + }); + toast({ + title: '已重置', + description: `已将 ${TOOL_DISPLAY_NAMES[toolId]} 的黑名单字段重置为默认`, + }); + } + } catch (error) { + toast({ + title: '重置失败', + description: String(error), + variant: 'destructive', + }); + } + }; + + const handleSave = async () => { + try { + setSaving(true); + const updatedConfig: ConfigWatchConfig = { + ...config, + sensitive_fields: sensitiveFields, + blacklist: blacklist, + }; + await updateWatchConfig(updatedConfig); + onConfigUpdate(updatedConfig); + toast({ + title: '保存成功', + description: '字段配置已更新', + }); + onOpenChange(false); + } catch (error) { + toast({ + title: '保存失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + const renderFieldList = (toolId: string, isSensitive: boolean) => { + const fields = isSensitive ? sensitiveFields[toolId] || [] : blacklist[toolId] || []; + const inputKey = `${isSensitive ? 'sensitive' : 'blacklist'}-${toolId}`; + + return ( + + +
+
+ {TOOL_DISPLAY_NAMES[toolId] || toolId} + {fields.length} 个字段 +
+ +
+
+ + {/* 字段列表 */} + +
+ {fields.map((field, index) => ( + + {field} + + + ))} + {fields.length === 0 && ( +

暂无字段

+ )} +
+
+ + {/* 添加字段输入框 */} +
+ + setNewFieldInputs({ + ...newFieldInputs, + [inputKey]: e.target.value, + }) + } + onKeyPress={(e) => handleAddField(toolId, isSensitive, e)} + className="font-mono text-sm" + /> + +
+
+
+ ); + }; + + const toolIds = ['claude-code', 'codex', 'gemini-cli']; + + return ( + + + + 字段管理 + + 管理各工具的敏感字段和黑名单字段,支持通配符(如 ui.*) + + + + + + 敏感字段 + 黑名单 + + + +
+ +

+ 在默认模式下,仅当这些字段发生变更时才会触发通知。包括 API Key、Base URL + 等关键配置。 +

+
+ +
{toolIds.map((id) => renderFieldList(id, true))}
+
+
+ + +
+ +

+ 这些字段的变更会被忽略,不会触发任何通知。通常包括主题、UI 设置等自动修改的配置。 +

+
+ +
+ {toolIds.map((id) => renderFieldList(id, false))} +
+
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 0f1f92d..f8981c3 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -16,6 +16,7 @@ import { Sun, Moon, Monitor, + Info, //预留 // User, } from 'lucide-react'; @@ -53,6 +54,7 @@ const secondaryItems = [ { id: 'provider-management', label: '供应商', icon: Building2 }, { id: 'help', label: '帮助', icon: HelpCircle }, { id: 'settings', label: '设置', icon: SettingsIcon }, + { id: 'about', label: '关于', icon: Info }, ]; export function AppSidebar({ diff --git a/src/hooks/useConfigWatch.ts b/src/hooks/useConfigWatch.ts new file mode 100644 index 0000000..e6ff0cc --- /dev/null +++ b/src/hooks/useConfigWatch.ts @@ -0,0 +1,109 @@ +/** + * 配置变更监听 Hook - 支持队列处理 + */ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import type { ExternalConfigChange } from '@/types/config-watch'; + +interface UseConfigWatchResult { + /** 当前变更信息 */ + change: ExternalConfigChange | null; + /** 是否显示对话框 */ + showDialog: boolean; + /** 关闭对话框 */ + closeDialog: () => void; + /** 队列中剩余的变更数量(不包括当前显示的) */ + queueLength: number; +} + +/** + * 监听配置文件外部变更(支持多工具变更队列) + */ +export function useConfigWatch(): UseConfigWatchResult { + const [change, setChange] = useState(null); + const [showDialog, setShowDialog] = useState(false); + const [queueLength, setQueueLength] = useState(0); + // 使用 ref 存储队列,避免闭包问题 + const queueRef = useRef([]); + // 标记是否正在显示对话框 + const isShowingRef = useRef(false); + + // 显示下一个待处理的变更 + const showNext = useCallback(() => { + if (queueRef.current.length > 0 && !isShowingRef.current) { + const next = queueRef.current[0]; + console.log( + '[ConfigWatch] 显示下一个变更:', + next.tool_id, + '队列剩余:', + queueRef.current.length, + ); + setChange(next); + setShowDialog(true); + setQueueLength(queueRef.current.length - 1); // 不包括当前显示的 + isShowingRef.current = true; + } + }, []); + + useEffect(() => { + // 监听配置外部变更事件 + const unlisten = listen('external-config-changed', (event) => { + const newChange = event.payload; + console.log('[ConfigWatch] 收到配置变更:', newChange.tool_id); + + // 如果队列中已有该工具的变更,替换为最新的(因为后端会标记旧的为 superseded) + const existingIndex = queueRef.current.findIndex((c) => c.tool_id === newChange.tool_id); + if (existingIndex >= 0) { + console.log('[ConfigWatch] 替换队列中的旧变更:', newChange.tool_id); + queueRef.current[existingIndex] = newChange; + } else { + // 添加到队列末尾 + queueRef.current.push(newChange); + console.log( + '[ConfigWatch] 添加到队列:', + newChange.tool_id, + '队列长度:', + queueRef.current.length, + ); + } + + // 如果当前没有显示对话框,立即显示 + if (!isShowingRef.current) { + showNext(); + } + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, [showNext]); + + const closeDialog = useCallback(() => { + console.log('[ConfigWatch] 关闭对话框,队列长度:', queueRef.current.length); + setShowDialog(false); + isShowingRef.current = false; + + // 从队列中移除当前变更 + if (queueRef.current.length > 0) { + queueRef.current.shift(); + console.log('[ConfigWatch] 移除当前变更,队列剩余:', queueRef.current.length); + } + + // 延迟清空当前显示的数据,避免对话框关闭动画时看到空内容 + setTimeout(() => { + setChange(null); + // 检查是否还有待处理的变更 + if (queueRef.current.length > 0) { + console.log('[ConfigWatch] 显示队列中的下一个变更'); + showNext(); + } + }, 300); + }, [showNext]); + + return { + change, + showDialog, + closeDialog, + queueLength, + }; +} diff --git a/src/lib/tauri-commands/config-watch.ts b/src/lib/tauri-commands/config-watch.ts new file mode 100644 index 0000000..fe1d529 --- /dev/null +++ b/src/lib/tauri-commands/config-watch.ts @@ -0,0 +1,103 @@ +/** + * 配置监听相关 Tauri 命令 + */ +import { invoke } from '@tauri-apps/api/core'; +import type { ConfigWatchConfig, ConfigChangeRecord } from '@/types/config-watch'; + +/** + * 阻止外部变更(恢复快照) + */ +export async function blockExternalChange(toolId: string): Promise { + await invoke('block_external_change', { toolId }); +} + +/** + * 允许外部变更(更新快照) + */ +export async function allowExternalChange(toolId: string): Promise { + await invoke('allow_external_change', { toolId }); +} + +/** + * 获取监听配置 + */ +export async function getWatchConfig(): Promise { + return await invoke('get_watch_config'); +} + +/** + * 更新监听配置 + */ +export async function updateWatchConfig(config: ConfigWatchConfig): Promise { + await invoke('update_watch_config', { config }); +} + +// ==================== 配置守护管理 ==================== + +/** + * 更新敏感字段配置 + */ +export async function updateSensitiveFields(toolId: string, fields: string[]): Promise { + await invoke('update_sensitive_fields', { toolId, fields }); +} + +/** + * 更新黑名单配置 + */ +export async function updateBlacklist(toolId: string, fields: string[]): Promise { + await invoke('update_blacklist', { toolId, fields }); +} + +/** + * 获取默认敏感字段配置 + */ +export async function getDefaultSensitiveFields(): Promise> { + return await invoke('get_default_sensitive_fields'); +} + +/** + * 获取默认黑名单配置 + */ +export async function getDefaultBlacklist(): Promise> { + return await invoke('get_default_blacklist'); +} + +// ==================== 变更日志管理 ==================== + +/** + * 获取配置变更日志 + */ +export async function getChangeLogs( + toolId?: string, + limit?: number, +): Promise { + return await invoke('get_change_logs', { toolId, limit }); +} + +/** + * 分页获取配置变更日志 + */ +export async function getChangeLogsPage( + page: number, + pageSize: number, +): Promise<[ConfigChangeRecord[], number]> { + return await invoke('get_change_logs_page', { page, pageSize }); +} + +/** + * 清除配置变更日志 + */ +export async function clearChangeLogs(toolId?: string): Promise { + await invoke('clear_change_logs', { toolId }); +} + +/** + * 更新变更日志的用户操作 + */ +export async function updateChangeLogAction( + toolId: string, + timestamp: string, + action: string, +): Promise { + await invoke('update_change_log_action', { toolId, timestamp, action }); +} diff --git a/src/lib/tauri-commands/config.ts b/src/lib/tauri-commands/config.ts index dc8509c..0b79dde 100644 --- a/src/lib/tauri-commands/config.ts +++ b/src/lib/tauri-commands/config.ts @@ -13,8 +13,6 @@ import type { JsonValue, TestProxyResult, ProxyTestConfig, - ExternalConfigChange, - ImportExternalChangeResult, } from './types'; // ==================== 全局配置 ==================== @@ -170,71 +168,6 @@ export async function getGeminiSchema(): Promise { return await invoke('get_gemini_schema'); } -// ==================== 配置监听 ==================== - -/** - * 获取配置监听器状态 - */ -export async function getWatcherStatus(): Promise { - return await invoke('get_watcher_status'); -} - -/** - * 启动配置监听器(如果需要) - */ -export async function startWatcherIfNeeded(): Promise { - return await invoke('start_watcher_if_needed'); -} - -/** - * 停止配置监听器 - */ -export async function stopWatcher(): Promise { - return await invoke('stop_watcher'); -} - -/** - * 保存配置监听器设置 - */ -export async function saveWatcherSettings( - enabled: boolean, - pollIntervalMs?: number, -): Promise { - await invoke('save_watcher_settings', { - enabled, - pollIntervalMs, - }); -} - -/** - * 获取外部配置变更列表 - */ -export async function getExternalChanges(): Promise { - return await invoke('get_external_changes'); -} - -/** - * 确认外部配置变更 - */ -export async function ackExternalChange(tool: string): Promise { - return await invoke('ack_external_change', { tool }); -} - -/** - * 导入原生配置变更为 Profile - */ -export async function importNativeChange( - tool: string, - profile: string, - asNew: boolean, -): Promise { - return await invoke('import_native_change', { - tool, - profile, - asNew, - }); -} - // ==================== 单实例模式配置 ==================== /** diff --git a/src/lib/tauri-commands/index.ts b/src/lib/tauri-commands/index.ts index 30b4566..f38be4d 100644 --- a/src/lib/tauri-commands/index.ts +++ b/src/lib/tauri-commands/index.ts @@ -10,6 +10,9 @@ export * from './tool'; // 配置管理 export * from './config'; +// 配置监听 +export * from './config-watch'; + // 代理管理 export * from './proxy'; diff --git a/src/lib/tauri-commands/types.ts b/src/lib/tauri-commands/types.ts index d4cf3bc..5465d3b 100644 --- a/src/lib/tauri-commands/types.ts +++ b/src/lib/tauri-commands/types.ts @@ -195,24 +195,6 @@ export interface ClaudeSettingsPayload { extraConfig?: JsonObject | null; } -export interface ExternalConfigChange { - tool_id: string; - path: string; - checksum?: string; - detected_at: string; - dirty: boolean; - timestamp?: string; - fallback_poll?: boolean; -} - -export interface ImportExternalChangeResult { - profileName: string; - wasNew: boolean; - replaced: boolean; - beforeChecksum?: string | null; - checksum?: string | null; -} - export interface TestProxyResult { success: boolean; status: number; diff --git a/src/pages/AboutPage/index.tsx b/src/pages/AboutPage/index.tsx new file mode 100644 index 0000000..df3b6e2 --- /dev/null +++ b/src/pages/AboutPage/index.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react'; +import { open } from '@tauri-apps/plugin-shell'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Info, RefreshCw, Github, Globe } from 'lucide-react'; +import { getCurrentAppVersion } from '@/services/update'; +import { PageContainer } from '@/components/layout/PageContainer'; +import type { UpdateInfo } from '@/lib/tauri-commands'; +import duckLogo from '@/assets/duck-logo.png'; + +interface AboutPageProps { + updateInfo?: UpdateInfo | null; + onUpdateCheck?: () => void; +} + +export function AboutPage({ onUpdateCheck }: AboutPageProps) { + const [version, setVersion] = useState('Loading...'); + + useEffect(() => { + getCurrentAppVersion() + .then(setVersion) + .catch((err) => { + console.error('Failed to get version:', err); + setVersion('Unknown'); + }); + }, []); + + return ( + +
+

关于 DuckCoding

+

应用信息与版本管理

+
+ +
+ + + + + 关于 DuckCoding + + 应用信息与版本管理 + + +
+
+ DuckCoding Logo +
+ + v{version} + +
+ +
+

DuckCoding

+

+ 一个专为开发者设计的现代化 AI 辅助编程工具,集成多种大模型能力,提供高效的编码体验。 +

+
+ +
+ + + + + +
+
+
+ + + + 开源协议 + + +

DuckCoding 是一个开源项目,遵循 MIT 许可证。

+

Copyright © 2025 DuckCoding Contributors.

+
+
+
+
+ ); +} diff --git a/src/pages/SettingsPage/components/AboutTab.tsx b/src/pages/SettingsPage/components/AboutTab.tsx deleted file mode 100644 index a86d6d0..0000000 --- a/src/pages/SettingsPage/components/AboutTab.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useEffect, useState } from 'react'; -import { open } from '@tauri-apps/plugin-shell'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Info, RefreshCw, Github, Globe } from 'lucide-react'; -import { getCurrentAppVersion } from '@/services/update'; -import duckLogo from '@/assets/duck-logo.png'; - -interface AboutTabProps { - onCheckUpdate: () => void; -} - -export function AboutTab({ onCheckUpdate }: AboutTabProps) { - const [version, setVersion] = useState('Loading...'); - - useEffect(() => { - getCurrentAppVersion() - .then(setVersion) - .catch((err) => { - console.error('Failed to get version:', err); - setVersion('Unknown'); - }); - }, []); - - return ( -
- - - - - 关于 DuckCoding - - 应用信息与版本管理 - - -
-
- DuckCoding Logo -
- - v{version} - -
- -
-

DuckCoding

-

- 一个专为开发者设计的现代化 AI 辅助编程工具,集成多种大模型能力,提供高效的编码体验。 -

-
- -
- - - - - -
-
-
- - - - 开源协议 - - -

DuckCoding 是一个开源项目,遵循 MIT 许可证。

-

Copyright © 2025 DuckCoding Contributors.

-
-
-
- ); -} diff --git a/src/pages/SettingsPage/components/ConfigGuardTab.tsx b/src/pages/SettingsPage/components/ConfigGuardTab.tsx new file mode 100644 index 0000000..6c3eb04 --- /dev/null +++ b/src/pages/SettingsPage/components/ConfigGuardTab.tsx @@ -0,0 +1,255 @@ +/** + * 配置守护设置标签页 + */ +import { useState, useEffect } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Loader2, Save, AlertCircle, Info, Settings, History } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { getWatchConfig, updateWatchConfig } from '@/lib/tauri-commands'; +import type { ConfigWatchConfig, WatchMode } from '@/types/config-watch'; +import { WATCH_MODE_LABELS, WATCH_MODE_DESCRIPTIONS } from '@/types/config-watch'; +import { FieldManagementDialog } from '@/components/dialogs/FieldManagementDialog'; +import { ChangeLogDialog } from '@/components/dialogs/ChangeLogDialog'; + +export function ConfigGuardTab() { + const { toast } = useToast(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [config, setConfig] = useState(null); + const [enabled, setEnabled] = useState(false); + const [mode, setMode] = useState('default'); + const [scanInterval, setScanInterval] = useState(5); + const [fieldsDialogOpen, setFieldsDialogOpen] = useState(false); + const [historyDialogOpen, setHistoryDialogOpen] = useState(false); + + // 加载配置 + useEffect(() => { + loadConfig(); + }, []); + + const loadConfig = async () => { + try { + setLoading(true); + const watchConfig = await getWatchConfig(); + setConfig(watchConfig); + setEnabled(watchConfig.enabled); + setMode(watchConfig.mode); + setScanInterval(watchConfig.scan_interval); + } catch (error) { + toast({ + title: '加载失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!config) return; + + try { + setSaving(true); + const updatedConfig: ConfigWatchConfig = { + ...config, + enabled, + mode, + scan_interval: scanInterval, + }; + await updateWatchConfig(updatedConfig); + setConfig(updatedConfig); + toast({ + title: '保存成功', + description: '配置守护设置已更新', + }); + } catch (error) { + toast({ + title: '保存失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + const hasChanges = () => { + if (!config) return false; + return ( + enabled !== config.enabled || mode !== config.mode || scanInterval !== config.scan_interval + ); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!config) { + return ( +
+ +

无法加载配置守护设置

+ +
+ ); + } + + return ( +
+ + + 配置守护设置 + 自动监控工具的原生配置文件,防止外部修改导致配置不一致 + + + {/* 守护开关 */} +
+
+ +

自动检测并通知配置文件的外部变更

+
+ +
+ + {/* 守护模式 */} +
+ + setMode(v as WatchMode)} + disabled={!enabled} + className="space-y-3" + > +
+ +
+ +

{WATCH_MODE_DESCRIPTIONS.default}

+
+
+ +
+ +
+ +

{WATCH_MODE_DESCRIPTIONS.full}

+
+
+
+
+ + {/* 扫描间隔 */} +
+ +
+ setScanInterval(Number(e.target.value))} + disabled={!enabled} + className="w-24" + /> + +
+

建议设置为 3-10 秒之间,过短会影响性能

+
+ + {/* 提示信息 */} +
+ +

+ 当检测到配置文件被外部修改时,系统会弹出对话框让您选择: + 阻止变更(恢复到上次保存的状态)或 + 允许变更(更新配置快照)。 +

+
+
+ + + + + + +
+ + {/* 字段管理对话框 */} + { + setConfig(updatedConfig); + loadConfig(); // 重新加载以确保同步 + }} + /> + + {/* 变更历史对话框 */} + +
+ ); +} diff --git a/src/pages/SettingsPage/components/ConfigManagementTab.tsx b/src/pages/SettingsPage/components/ConfigManagementTab.tsx deleted file mode 100644 index 574ed6a..0000000 --- a/src/pages/SettingsPage/components/ConfigManagementTab.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { Switch } from '@/components/ui/switch'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Loader2, RefreshCw, Wand2, Radio } from 'lucide-react'; -import { listen } from '@tauri-apps/api/event'; -import { - ackExternalChange, - pmGetActiveProfileName, - getExternalChanges, - getGlobalConfig, - importNativeChange, - getWatcherStatus, - saveWatcherSettings, - type ExternalConfigChange, - type ToolId, -} from '@/lib/tauri-commands'; -import { useToast } from '@/hooks/use-toast'; - -export function ConfigManagementTab() { - const { toast } = useToast(); - const [loading, setLoading] = useState(false); - const [savingWatch, setSavingWatch] = useState(false); - const [externalChanges, setExternalChanges] = useState([]); - const [notifyEnabled, setNotifyEnabled] = useState(true); - const [pollIntervalMs, setPollIntervalMs] = useState(500); - const [nameDialog, setNameDialog] = useState<{ - open: boolean; - toolId: string; - defaultName: string; - }>({ open: false, toolId: '', defaultName: '' }); - const [inputName, setInputName] = useState(''); - - const loadAll = useCallback(async () => { - setLoading(true); - try { - const [changes, watcherOn, cfg] = await Promise.all([ - getExternalChanges().catch(() => []), - getWatcherStatus().catch(() => false), - getGlobalConfig().catch(() => null), - ]); - setExternalChanges(changes); - setNotifyEnabled(watcherOn); - if (cfg?.external_poll_interval_ms !== undefined) { - setPollIntervalMs(cfg.external_poll_interval_ms); - } - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void loadAll(); - }, [loadAll]); - - const saveWatchSettings = useCallback(async () => { - setSavingWatch(true); - try { - await saveWatcherSettings(notifyEnabled, pollIntervalMs); - toast({ - title: '监听设置已保存', - description: notifyEnabled - ? `已开启监听,间隔 ${pollIntervalMs}ms` - : '监听已关闭,仅手动刷新生效', - }); - } catch (error) { - toast({ - title: '保存失败', - description: String(error), - variant: 'destructive', - }); - } finally { - setSavingWatch(false); - } - }, [notifyEnabled, pollIntervalMs, toast]); - - const handleAck = useCallback( - async (toolId: string) => { - try { - await ackExternalChange(toolId); - toast({ title: '已标记为已处理', description: toolId }); - void loadAll(); - } catch (error) { - toast({ title: '操作失败', description: String(error), variant: 'destructive' }); - } - }, - [loadAll, toast], - ); - - const handleImport = useCallback( - async (toolId: string, asNew: boolean) => { - if (asNew) { - const defaultName = `imported-${toolId}`; - setInputName(defaultName); - setNameDialog({ open: true, toolId, defaultName }); - return; - } - - // 覆盖当前:直接用当前激活 profile - const profileName = (await pmGetActiveProfileName(toolId as ToolId)) || 'default'; - try { - await importNativeChange(toolId, profileName, false); - toast({ - title: '导入完成', - description: `已覆盖 ${profileName}`, - }); - void loadAll(); - } catch (error) { - console.error('[ConfigManagement] import failed', error); - toast({ title: '导入失败', description: String(error), variant: 'destructive' }); - } - }, - [loadAll, toast], - ); - - const handleConfirmImportNew = useCallback(async () => { - const targetName = inputName.trim(); - if (!targetName) { - toast({ - title: '导入失败', - description: '请输入非空的 Profile 名称', - variant: 'destructive', - }); - return; - } - const toolId = nameDialog.toolId; - try { - await importNativeChange(toolId, targetName, true); - toast({ - title: '导入完成', - description: `已导入为 ${targetName}`, - }); - setNameDialog({ open: false, toolId: '', defaultName: '' }); - void loadAll(); - } catch (error) { - console.error('[ConfigManagement] import new failed', error); - toast({ title: '导入失败', description: String(error), variant: 'destructive' }); - } - }, [inputName, loadAll, nameDialog.toolId, toast]); - - // 监听实时外部改动事件,前端直接追加 - useEffect(() => { - let unlisten: (() => void) | undefined; - const setup = async () => { - try { - unlisten = await listen('external-config-changed', (event) => { - setExternalChanges((prev) => { - const payload = event.payload; - const filtered = prev.filter( - (c) => !(c.tool_id === payload.tool_id && c.path === payload.path), - ); - return [...filtered, payload]; - }); - }); - } catch (error) { - toast({ - title: '监听事件失败', - description: String(error), - variant: 'destructive', - }); - } - }; - void setup(); - return () => { - if (unlisten) unlisten(); - }; - }, [toast]); - - // 切换开关即刻应用 - const handleToggleWatch = useCallback( - async (enabled: boolean) => { - const previous = notifyEnabled; - setNotifyEnabled(enabled); - setSavingWatch(true); - try { - await saveWatcherSettings(enabled, pollIntervalMs); - const latest = await getWatcherStatus().catch(() => enabled); - setNotifyEnabled(latest); - toast({ - title: '监听设置已更新', - description: latest - ? `已开启监听,间隔 ${pollIntervalMs}ms` - : '监听已关闭,仅手动刷新生效', - }); - } catch (error) { - setNotifyEnabled(previous); - toast({ - title: '保存失败', - description: String(error), - variant: 'destructive', - }); - } finally { - setSavingWatch(false); - } - }, - [notifyEnabled, pollIntervalMs, toast], - ); - - return ( -
-
- -
-

配置文件监控

-
-
- - - - setNameDialog((prev) => (open ? prev : { open: false, toolId: '', defaultName: '' })) - } - > - e.stopPropagation()}> - - 导入为新配置 - -
-
- 请输入新配置名称(工具:{nameDialog.toolId || '-'}) -
- setInputName(e.target.value)} - placeholder={nameDialog.defaultName || 'new-profile'} - /> -
- - - - -
-
- -
-
-
-
-
实时监听
-

- 打开后自动监听文件改动;关闭时仅在手动刷新时检查。 -

-
- -
-
-
- 轮询间隔 (ms) - setPollIntervalMs(Number(e.target.value) || 0)} - /> -
- - -
-
- -
-
-
-
外部改动
-

- 捕获配置文件的外部修改,可导入为新 Profile、覆盖当前或直接标记已处理。 -

-
- 0 ? 'destructive' : 'outline'}> - {externalChanges.length} 项 - -
-
- {externalChanges.length === 0 ? ( -
暂无外部改动
- ) : ( - externalChanges.map((change) => ( -
-
- {change.tool_id} - {change.path} - - 检测时间:{new Date(change.detected_at).toLocaleString()} - -
-
- - - -
-
- )) - )} -
-
-
-
- ); -} diff --git a/src/pages/SettingsPage/index.tsx b/src/pages/SettingsPage/index.tsx index 5f003bf..70cfb90 100644 --- a/src/pages/SettingsPage/index.tsx +++ b/src/pages/SettingsPage/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Loader2, Save } from 'lucide-react'; @@ -8,16 +8,13 @@ import { useSettingsForm } from './hooks/useSettingsForm'; import { BasicSettingsTab } from './components/BasicSettingsTab'; import { ProxySettingsTab } from './components/ProxySettingsTab'; import { LogSettingsTab } from './components/LogSettingsTab'; -import { AboutTab } from './components/AboutTab'; -import { ConfigManagementTab } from './components/ConfigManagementTab'; -import type { GlobalConfig, UpdateInfo } from '@/lib/tauri-commands'; +import { ConfigGuardTab } from './components/ConfigGuardTab'; +import type { GlobalConfig } from '@/lib/tauri-commands'; interface SettingsPageProps { globalConfig: GlobalConfig | null; configLoading: boolean; onConfigChange: () => void; - updateInfo?: UpdateInfo | null; - onUpdateCheck?: () => void; initialTab?: string; restrictToTab?: string; // 限制只能访问特定 tab } @@ -25,8 +22,6 @@ interface SettingsPageProps { export function SettingsPage({ globalConfig, onConfigChange, - updateInfo: _updateInfo, - onUpdateCheck, initialTab = 'basic', restrictToTab, }: SettingsPageProps) { @@ -70,22 +65,6 @@ export function SettingsPage({ testProxy, } = useSettingsForm({ initialConfig: globalConfig, onConfigChange }); - // 监听来自App组件的导航到关于tab的事件 - useEffect(() => { - const handleNavigateToAboutTab = () => { - setActiveTab('about'); - }; - - window.addEventListener('navigate-to-about-tab', handleNavigateToAboutTab); - // 保持向下兼容,同时也监听 update tab - window.addEventListener('navigate-to-update-tab', handleNavigateToAboutTab); - - return () => { - window.removeEventListener('navigate-to-about-tab', handleNavigateToAboutTab); - window.removeEventListener('navigate-to-update-tab', handleNavigateToAboutTab); - }; - }, []); - // 测试代理连接 const handleTestProxy = async () => { const result = await testProxy(); @@ -163,10 +142,10 @@ export function SettingsPage({ 系统设置 - 配置管理 + 配置守护 代理设置 @@ -174,9 +153,6 @@ export function SettingsPage({ 日志配置 - - 关于 - {/* 系统设置 */} @@ -213,14 +189,9 @@ export function SettingsPage({ - {/* 配置管理 */} - - - - - {/* 关于 */} - - onUpdateCheck?.()} /> + {/* 配置守护 */} + + diff --git a/src/types/config-watch.ts b/src/types/config-watch.ts new file mode 100644 index 0000000..58e5f92 --- /dev/null +++ b/src/types/config-watch.ts @@ -0,0 +1,126 @@ +/** + * 配置监听系统类型定义 + */ + +/** + * 监听模式 + */ +export type WatchMode = 'default' | 'full'; + +/** + * 配置监听配置 + */ +export interface ConfigWatchConfig { + /** 是否启用配置守护 */ + enabled: boolean; + /** 监听模式 */ + mode: WatchMode; + /** 扫描间隔(秒) */ + scan_interval: number; + /** 黑名单字段(按工具分组) */ + blacklist: Record; + /** 敏感字段(按工具分组) */ + sensitive_fields: Record; +} + +/** + * 变更类型 + */ +export type ChangeType = 'modified' | 'added' | 'deleted'; + +/** + * 操作类型 + */ +export type ActionType = 'allow' | 'block' | 'superseded' | 'expired'; + +/** + * 字段变更 + */ +export interface FieldChange { + /** 字段路径 */ + path: string; + /** 旧值(删除时为 Some,新增时为 None) */ + old_value?: any; + /** 新值(新增时为 Some,删除时为 None) */ + new_value?: any; + /** 变更类型 */ + change_type: ChangeType; +} + +/** + * 外部配置变更事件 + */ +export interface ExternalConfigChange { + /** 工具 ID */ + tool_id: string; + /** 配置文件路径 */ + path: string; + /** 变更字段列表 */ + changed_fields: FieldChange[]; + /** 是否包含敏感字段 */ + is_sensitive: boolean; +} + +/** + * 配置变更记录 + */ +export interface ConfigChangeRecord { + /** 工具 ID */ + tool_id: string; + /** 变更时间 */ + timestamp: string; + /** 变更字段路径列表 */ + changed_fields: string[]; + /** 是否包含敏感字段 */ + is_sensitive: boolean; + /** 变更前的值(字段路径 -> 值) */ + before_values: Record; + /** 变更后的值(字段路径 -> 值) */ + after_values: Record; + /** 用户操作(allow/block/superseded/expired) */ + action?: ActionType; +} + +/** + * 工具显示名称映射 + */ +export const TOOL_DISPLAY_NAMES: Record = { + 'claude-code': 'Claude Code', + codex: 'Codex', + 'gemini-cli': 'Gemini CLI', +}; + +/** + * 监听模式显示名称 + */ +export const WATCH_MODE_LABELS: Record = { + default: '默认模式', + full: '全量模式', +}; + +/** + * 监听模式描述 + */ +export const WATCH_MODE_DESCRIPTIONS: Record = { + default: '仅通知 API Key 和 Base URL 变更,忽略其他配置修改', + full: '通知所有非黑名单字段的配置变更', +}; + +/** + * 变更类型显示文本 + */ +export const CHANGE_TYPE_LABELS: Record = { + modified: '修改', + added: '新增', + deleted: '删除', +}; + +/** + * 操作类型显示文本 + */ +export const ACTION_TYPE_LABELS: Record = { + allow: '已允许', + block: '已阻止', + superseded: '已累加', + expired: '已过期', +}; From 31d3a8f1a040b4976b395b08838fa7b7b4ef86bd Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:59:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(settings):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=AE=A1=E7=90=86=20Tab=20=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20TypeScript=20=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 SettingsPageProps 缺少 updateInfo 和 onUpdateCheck 属性 - 添加 UpdateTab 导入和使用 - 在设置页面添加更新管理标签页 - 解决 CI 构建失败问题(TypeScript 编译错误) --- src/pages/SettingsPage/index.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/pages/SettingsPage/index.tsx b/src/pages/SettingsPage/index.tsx index 70cfb90..c4933a5 100644 --- a/src/pages/SettingsPage/index.tsx +++ b/src/pages/SettingsPage/index.tsx @@ -9,21 +9,26 @@ import { BasicSettingsTab } from './components/BasicSettingsTab'; import { ProxySettingsTab } from './components/ProxySettingsTab'; import { LogSettingsTab } from './components/LogSettingsTab'; import { ConfigGuardTab } from './components/ConfigGuardTab'; -import type { GlobalConfig } from '@/lib/tauri-commands'; +import { UpdateTab } from './components/UpdateTab'; +import type { GlobalConfig, UpdateInfo } from '@/lib/tauri-commands'; interface SettingsPageProps { globalConfig: GlobalConfig | null; configLoading: boolean; onConfigChange: () => void; + updateInfo: UpdateInfo | null; initialTab?: string; restrictToTab?: string; // 限制只能访问特定 tab + onUpdateCheck: () => void; } export function SettingsPage({ globalConfig, onConfigChange, + updateInfo, initialTab = 'basic', restrictToTab, + onUpdateCheck, }: SettingsPageProps) { const { toast } = useToast(); const [activeTab, setActiveTab] = useState(initialTab); @@ -153,6 +158,9 @@ export function SettingsPage({ 日志配置 + + 更新管理 + {/* 系统设置 */} @@ -193,6 +201,11 @@ export function SettingsPage({ + + {/* 更新管理 */} + + + {/* 保存按钮 - 仅在代理设置时显示 */} From c35e4402b5e9b41562ebe13716462c4b238905c3 Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:43:54 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(settings):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=B7=BB=E5=8A=A0=E7=9A=84=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完全移除更新管理 Tab(包括 TabsTrigger 和 TabsContent) - 删除 UpdateTab 组件导入 - 将 updateInfo 和 onUpdateCheck props 改为可选(与上游一致) - 使用下划线前缀标记未使用的 props(_updateInfo, _onUpdateCheck) --- src/pages/SettingsPage/index.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/pages/SettingsPage/index.tsx b/src/pages/SettingsPage/index.tsx index c4933a5..73fe914 100644 --- a/src/pages/SettingsPage/index.tsx +++ b/src/pages/SettingsPage/index.tsx @@ -9,26 +9,25 @@ import { BasicSettingsTab } from './components/BasicSettingsTab'; import { ProxySettingsTab } from './components/ProxySettingsTab'; import { LogSettingsTab } from './components/LogSettingsTab'; import { ConfigGuardTab } from './components/ConfigGuardTab'; -import { UpdateTab } from './components/UpdateTab'; import type { GlobalConfig, UpdateInfo } from '@/lib/tauri-commands'; interface SettingsPageProps { globalConfig: GlobalConfig | null; configLoading: boolean; onConfigChange: () => void; - updateInfo: UpdateInfo | null; + updateInfo?: UpdateInfo | null; initialTab?: string; restrictToTab?: string; // 限制只能访问特定 tab - onUpdateCheck: () => void; + onUpdateCheck?: () => void; } export function SettingsPage({ globalConfig, onConfigChange, - updateInfo, + updateInfo: _updateInfo, initialTab = 'basic', restrictToTab, - onUpdateCheck, + onUpdateCheck: _onUpdateCheck, }: SettingsPageProps) { const { toast } = useToast(); const [activeTab, setActiveTab] = useState(initialTab); @@ -158,9 +157,6 @@ export function SettingsPage({ 日志配置 - - 更新管理 - {/* 系统设置 */} @@ -201,11 +197,6 @@ export function SettingsPage({ - - {/* 更新管理 */} - - - {/* 保存按钮 - 仅在代理设置时显示 */} From ae2a32658b528d1ae87dad48a9d899e272051daa Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:50:53 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(settings):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=BA=9F=E5=BC=83=E7=9A=84=E5=AE=9E=E9=AA=8C=E6=80=A7=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除设置页面中已废弃的实验性功能组件,包括: - 删除 ExperimentalSettingsTab 组件(透明代理 UI,已迁移到独立页面) - 删除 InstallPackageSelector 组件(安装包选择器,更新功能已重构) - 删除 UpdateTab 组件(更新管理,功能已移至其他模块) - 删除 useMultiToolProxy hook(多工具代理钩子,已废弃) - 删除 StatisticsPage 页面(统计页面,未使用) - 清理 useSettingsForm 中的 generateProxyKey 废弃函数 清理范围: - 删除 1222 行代码 - 移除 6 个文件/组件 - 简化设置表单逻辑 此次清理遵循代码库演进方向,相关功能已在新架构中重新实现, 保持代码库整洁,减少维护负担。 --- .../components/ExperimentalSettingsTab.tsx | 187 ------- .../components/InstallPackageSelector.tsx | 260 ---------- .../SettingsPage/components/UpdateTab.tsx | 459 ------------------ .../SettingsPage/hooks/useMultiToolProxy.ts | 181 ------- .../SettingsPage/hooks/useSettingsForm.ts | 9 - src/pages/StatisticsPage/index.tsx | 126 ----- 6 files changed, 1222 deletions(-) delete mode 100644 src/pages/SettingsPage/components/ExperimentalSettingsTab.tsx delete mode 100644 src/pages/SettingsPage/components/InstallPackageSelector.tsx delete mode 100644 src/pages/SettingsPage/components/UpdateTab.tsx delete mode 100644 src/pages/SettingsPage/hooks/useMultiToolProxy.ts delete mode 100644 src/pages/StatisticsPage/index.tsx diff --git a/src/pages/SettingsPage/components/ExperimentalSettingsTab.tsx b/src/pages/SettingsPage/components/ExperimentalSettingsTab.tsx deleted file mode 100644 index 21f0474..0000000 --- a/src/pages/SettingsPage/components/ExperimentalSettingsTab.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; -import { Sparkles, AlertCircle, Loader2, Power } from 'lucide-react'; -import type { TransparentProxyStatus } from '@/lib/tauri-commands'; - -interface ExperimentalSettingsTabProps { - transparentProxyEnabled: boolean; - setTransparentProxyEnabled: (value: boolean) => void; - transparentProxyPort: number; - setTransparentProxyPort: (value: number) => void; - transparentProxyApiKey: string; - setTransparentProxyApiKey: (value: string) => void; - transparentProxyAllowPublic: boolean; - setTransparentProxyAllowPublic: (value: boolean) => void; - transparentProxyStatus: TransparentProxyStatus | null; - startingProxy: boolean; - stoppingProxy: boolean; - onGenerateProxyKey: () => void; - onStartProxy: () => void; - onStopProxy: () => void; -} - -export function ExperimentalSettingsTab({ - transparentProxyEnabled, - setTransparentProxyEnabled, - transparentProxyPort, - setTransparentProxyPort, - transparentProxyApiKey, - setTransparentProxyApiKey, - transparentProxyAllowPublic, - setTransparentProxyAllowPublic, - transparentProxyStatus, - startingProxy, - stoppingProxy, - onGenerateProxyKey, - onStartProxy, - onStopProxy, -}: ExperimentalSettingsTabProps) { - return ( -
-
- -

ClaudeCode 透明代理

-
- - - {/* 实验性功能警告 */} -
-
- -
-

实验性功能

-

- 此功能处于实验阶段,可能存在不稳定性。 -

-
-
-
- -
-
-
- -

- 允许 ClaudeCode 动态切换 API 配置,无需重启终端 -

-
- setTransparentProxyEnabled(e.target.checked)} - className="h-4 w-4 rounded border-slate-300" - /> -
- - {transparentProxyEnabled && ( - <> -
- - setTransparentProxyPort(parseInt(e.target.value) || 8787)} - /> -

- 透明代理服务器监听的本地端口,默认 8787 -

-
- -
-
- - -
- setTransparentProxyApiKey(e.target.value)} - /> -

用于验证透明代理请求的密钥

-
- -
-
- -

- 允许从非本机地址访问透明代理(不推荐) -

-
- setTransparentProxyAllowPublic(e.target.checked)} - className="h-4 w-4 rounded border-slate-300" - /> -
- - {/* 透明代理状态 */} - {transparentProxyStatus && ( -
-
-
-

代理状态

-

- {transparentProxyStatus.running - ? `运行中 (端口 ${transparentProxyStatus.port})` - : '未运行'} -

-
-
- {transparentProxyStatus.running ? ( - - ) : ( - - )} -
-
-
- )} - - )} -
-
- ); -} diff --git a/src/pages/SettingsPage/components/InstallPackageSelector.tsx b/src/pages/SettingsPage/components/InstallPackageSelector.tsx deleted file mode 100644 index 9bc571e..0000000 --- a/src/pages/SettingsPage/components/InstallPackageSelector.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Label } from '@/components/ui/label'; -import { Package, Download, CheckCircle, AlertCircle } from 'lucide-react'; - -interface PackageOption { - id: string; - name: string; - description: string; - url?: string; - icon: React.ReactNode; - recommended?: boolean; -} - -import type { UpdateInfo } from '@/lib/tauri-commands/types'; - -interface PlatformInfo { - is_windows: boolean; - is_macos: boolean; - is_linux: boolean; - arch: string; -} - -interface InstallPackageSelectorProps { - isOpen: boolean; - onClose: () => void; - updateInfo: UpdateInfo; - onDownloadSelected: (url: string) => void; - platformInfo: PlatformInfo; -} - -export function InstallPackageSelector({ - isOpen, - onClose, - updateInfo, - onDownloadSelected, - platformInfo, -}: InstallPackageSelectorProps) { - const [selectedPackage, setSelectedPackage] = useState(''); - const [isDownloading, setIsDownloading] = useState(false); - - // 根据平台生成可用选项 - const getAvailablePackages = (): PackageOption[] => { - if (!updateInfo?.update || !platformInfo) { - return []; - } - - const { update } = updateInfo; - - if (platformInfo.is_windows) { - return [ - { - id: 'windows_msi', - name: 'MSI 安装包', - description: '推荐的 Windows 安装包,支持静默安装和自动更新', - url: update.windows_msi, - icon: , - recommended: true, - }, - { - id: 'windows_exe', - name: 'EXE 安装包', - description: '传统的 Windows 安装程序', - url: update.windows_exe, - icon: , - }, - { - id: 'windows_portable', - name: '便携版', - description: '无需安装,解压即用,适合U盘使用', - url: update.windows, - icon: , - }, - ].filter((pkg) => pkg.url); // 只显示有URL的选项 - } - - if (platformInfo.is_macos) { - return [ - { - id: 'macos_dmg', - name: 'DMG 镜像', - description: '推荐的 macOS 安装包,拖拽即可安装', - url: update.macos_dmg, - icon: , - recommended: true, - }, - { - id: 'macos_pkg', - name: 'PKG 安装包', - description: '系统级安装包', - url: update.macos, - icon: , - }, - ].filter((pkg) => pkg.url); - } - - if (platformInfo.is_linux) { - const packages: PackageOption[] = [ - { - id: 'linux_appimage', - name: 'AppImage', - description: '通用 Linux 应用,无需安装,支持所有发行版', - url: update.linux_appimage, - icon: , - recommended: true, - }, - ]; - - // 根据发行版添加包管理器选项 - // 这里简化处理,实际可以检测发行版 - if (update.linux_deb) { - packages.push({ - id: 'linux_deb', - name: 'DEB 包', - description: '适用于 Ubuntu/Debian 及其衍生发行版', - url: update.linux_deb, - icon: , - }); - } - - if (update.linux_rpm) { - packages.push({ - id: 'linux_rpm', - name: 'RPM 包', - description: '适用于 Fedora/CentOS/RHEL 及其衍生发行版', - url: update.linux_rpm, - icon: , - }); - } - - if (update.linux) { - packages.push({ - id: 'linux_generic', - name: '通用版', - description: 'Linux 通用压缩包', - url: update.linux, - icon: , - }); - } - - return packages.filter((pkg) => pkg.url); - } - - return []; - }; - - const availablePackages = getAvailablePackages(); - - const handleDownload = async () => { - if (!selectedPackage) return; - - const selectedOption = availablePackages.find((pkg) => pkg.id === selectedPackage); - if (!selectedOption?.url) return; - - setIsDownloading(true); - try { - await onDownloadSelected(selectedOption.url); - onClose(); - } catch (error) { - console.error('Download failed:', error); - } finally { - setIsDownloading(false); - } - }; - - if (availablePackages.length === 0) { - return ( - - - - - - 无可用安装包 - - -
-

很抱歉,当前平台暂无可用的安装包。

-
-
-
- ); - } - - return ( - - - - - - 选择安装包格式 - - - 为您的{' '} - {platformInfo?.is_windows ? 'Windows' : platformInfo?.is_macos ? 'macOS' : 'Linux'} - 系统选择合适的安装包格式 - - - -
- - {availablePackages.map((pkg) => ( -
- - -
- ))} -
- -
- - -
-
-
-
- ); -} diff --git a/src/pages/SettingsPage/components/UpdateTab.tsx b/src/pages/SettingsPage/components/UpdateTab.tsx deleted file mode 100644 index 399b6af..0000000 --- a/src/pages/SettingsPage/components/UpdateTab.tsx +++ /dev/null @@ -1,459 +0,0 @@ -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Progress } from '@/components/ui/progress'; -import { Badge } from '@/components/ui/badge'; -import { - Download, - Check, - AlertCircle, - RefreshCw, - Zap, - Clock, - HardDrive, - Info, - Loader2, - RotateCcw, -} from 'lucide-react'; -import { useUpdate } from '@/hooks/useUpdate'; -import { InstallPackageSelector } from './InstallPackageSelector'; -import type { UpdateInfo } from '@/lib/tauri-commands'; - -interface UpdateTabProps { - updateInfo?: UpdateInfo | null; - onUpdateCheck?: () => void; -} - -export function UpdateTab({ updateInfo: externalUpdateInfo, onUpdateCheck }: UpdateTabProps) { - const [showReleaseNotes, setShowReleaseNotes] = useState(false); - const [showPackageSelector, setShowPackageSelector] = useState(false); - - const { - updateInfo, - updateStatus, - downloadProgress, - currentVersion, - platformInfo, - packageFormatInfo, - isChecking, - isDownloading, - error, - checkForUpdates, - downloadAndInstallSpecificPackage, - restartToUpdate, - formatFileSize, - formatSpeed, - formatEta, - isUpdateAvailable, - isUpdateDownloaded, - isUpdateInstalled, - isUpdateFailed, - downloadPercentage, - } = useUpdate({ externalUpdateInfo, onExternalUpdateCheck: onUpdateCheck }); - - const getStatusColor = () => { - switch (updateStatus) { - case 'Available': - return 'bg-blue-500'; - case 'Downloading': - return 'bg-yellow-500'; - case 'Downloaded': - return 'bg-green-500'; - case 'Installing': - return 'bg-purple-500'; - case 'Installed': - return 'bg-green-600'; - case 'Failed': - return 'bg-red-500'; - default: - return 'bg-gray-500'; - } - }; - - const getStatusText = () => { - switch (updateStatus) { - case 'Idle': - return '系统正常'; - case 'Checking': - return '检查更新中...'; - case 'Available': - return '有可用更新'; - case 'Downloading': - return '下载中...'; - case 'Downloaded': - return '下载完成'; - case 'Installing': - return '安装中...'; - case 'Installed': - return '安装完成,等待重启'; - case 'Failed': - return '更新失败'; - default: - return '未知状态'; - } - }; - - return ( -
- {/* 当前版本信息 */} - - - - - 当前版本信息 - - 查看当前应用版本和更新状态 - - -
-
-
当前版本
-
{currentVersion}
-
- - {getStatusText()} - -
- - {platformInfo && ( -
-
-
操作系统
-
- {platformInfo.is_windows && 'Windows'} - {platformInfo.is_macos && 'macOS'} - {platformInfo.is_linux && 'Linux'} -
-
-
-
架构
-
{platformInfo.arch}
-
-
- )} - - {packageFormatInfo && ( -
-
支持的包格式
-
- {packageFormatInfo.preferred_formats.map((format: string, index: number) => ( - - {format.replace(/_/g, '.').toUpperCase()} - - ))} -
-
- )} -
-
- - {/* 更新信息 */} - {updateInfo && updateInfo.has_update && ( - - - - - 发现新版本 - - 最新版本 {updateInfo.latest_version} 可供下载 - - -
-
-
新版本
-
- {updateInfo.latest_version} -
-
-
-
文件大小
-
- {updateInfo.file_size ? formatFileSize(updateInfo.file_size) : '未知'} -
-
-
- - {updateInfo.release_notes && ( -
- - {showReleaseNotes && ( -
-
{updateInfo.release_notes}
-
- )} -
- )} -
-
- )} - - {/* 下载进度 */} - {isDownloading && downloadProgress && !isUpdateDownloaded && ( - - - - - 下载更新 - - - -
-
- 下载进度 - {downloadPercentage.toFixed(1)}% -
- -
- -
-
- - - {formatFileSize(downloadProgress.downloaded_bytes)} / - {formatFileSize(downloadProgress.total_bytes)} - -
- {downloadProgress.speed && ( -
- - {formatSpeed(downloadProgress.speed)} -
- )} - {downloadProgress.eta && ( -
- - 剩余 {formatEta(downloadProgress.eta)} -
- )} -
-
-
- )} - - {/* 操作按钮 */} - - - 更新操作 - 检查、下载和安装应用更新 - - - {/* 统一的按钮状态管理 - 使用互斥条件避免重复显示 */} - {(() => { - // 优先级:安装完成 > 下载完成 > 下载中 > 安装中 > 有更新 > 检查失败 > 无更新 > 初始状态 - - // 1. 安装完成状态 - if (isUpdateInstalled) { - return ( -
-
- - 更新安装完成 -
-
请重启应用以使用新版本
- -
- ); - } - - // 2. 下载完成状态 - if (isUpdateDownloaded) { - return ( -
-
- - 更新已下载完成 -
-
点击下方按钮重启应用以完成安装
- -
- ); - } - - // 3. 下载中状态 - if (isDownloading) { - return ( -
-
- - 正在下载更新... -
-
-
- 下载进度 - {downloadPercentage.toFixed(1)}% -
- - {downloadProgress && ( -
- {formatFileSize(downloadProgress.downloaded_bytes)} / - {formatFileSize(downloadProgress.total_bytes)} -
- )} -
- -
- ); - } - - // 4. 安装中状态 - if (updateStatus === 'Installing') { - return ( -
-
- - 正在安装更新... -
- -
- ); - } - - // 5. 发现更新状态 - if (isUpdateAvailable && updateInfo) { - return ( - - ); - } - - // 6. 更新失败状态 - if (isUpdateFailed) { - return ( -
-
- - 更新失败 -
- {error && ( -
{error}
- )} - -
- ); - } - - // 7. 无更新状态 - if (updateInfo && updateInfo.has_update === false) { - return ( -
-
- - 已是最新版本 -
- -
- ); - } - - // 8. 检查失败状态 (有错误但没有明确的状态) - if (error && updateInfo) { - return ( - - ); - } - - // 9. 初始状态 - 未检查过 - return ( - - ); - })()} -
-
- - {/* 安装包选择器 */} - {updateInfo && platformInfo && ( - setShowPackageSelector(false)} - updateInfo={updateInfo} - onDownloadSelected={downloadAndInstallSpecificPackage} - platformInfo={platformInfo} - /> - )} -
- ); -} diff --git a/src/pages/SettingsPage/hooks/useMultiToolProxy.ts b/src/pages/SettingsPage/hooks/useMultiToolProxy.ts deleted file mode 100644 index 4d98468..0000000 --- a/src/pages/SettingsPage/hooks/useMultiToolProxy.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { useState, useCallback, useEffect } from 'react'; -import { - startToolProxy, - stopToolProxy, - getAllProxyStatus, - getProxyConfig, - updateProxyConfig, - type AllProxyStatus, - type ToolProxyConfig, - type ToolId, -} from '@/lib/tauri-commands'; - -// 工具元数据 -export interface ToolMetadata { - id: string; - name: string; - description: string; - defaultPort: number; -} - -export const SUPPORTED_TOOLS: ToolMetadata[] = [ - { - id: 'claude-code', - name: 'Claude Code', - description: 'Anthropic Claude 编程助手', - defaultPort: 8787, - }, - { - id: 'codex', - name: 'Codex', - description: 'OpenAI Codex 编程助手', - defaultPort: 8788, - }, - { - id: 'gemini-cli', - name: 'Gemini CLI', - description: 'Google Gemini 命令行工具', - defaultPort: 8789, - }, -]; - -// 默认工具配置 -function getDefaultToolConfig(toolId: string): ToolProxyConfig { - const tool = SUPPORTED_TOOLS.find((t) => t.id === toolId); - return { - enabled: false, - port: tool?.defaultPort || 8790, - local_api_key: null, - real_api_key: null, - real_base_url: null, - real_model_provider: null, - real_profile_name: null, - allow_public: false, - session_endpoint_config_enabled: false, - auto_start: false, - }; -} - -export function useMultiToolProxy() { - const [allProxyStatus, setAllProxyStatus] = useState({}); - const [loadingTools, setLoadingTools] = useState>(new Set()); - const [toolConfigs, setToolConfigs] = useState>({}); - const [savingConfig, setSavingConfig] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - - // 加载单个工具配置 - const loadToolConfig = useCallback(async (toolId: string) => { - try { - const config = await getProxyConfig(toolId as ToolId); - return config || getDefaultToolConfig(toolId); - } catch (error) { - console.error(`Failed to load ${toolId} config:`, error); - return getDefaultToolConfig(toolId); - } - }, []); - - // 加载所有工具配置 - const loadAllConfigs = useCallback(async () => { - const configs: Record = {}; - for (const tool of SUPPORTED_TOOLS) { - configs[tool.id] = await loadToolConfig(tool.id); - } - setToolConfigs(configs); - }, [loadToolConfig]); - - // 加载所有工具的代理状态 - const loadAllProxyStatus = useCallback(async () => { - try { - const status = await getAllProxyStatus(); - setAllProxyStatus(status); - } catch (error) { - console.error('Failed to load all proxy status:', error); - throw error; - } - }, []); - - // 初始化加载 - useEffect(() => { - loadAllConfigs().catch(console.error); - loadAllProxyStatus().catch(console.error); - }, [loadAllConfigs, loadAllProxyStatus]); - - // 更新单个工具的配置(本地状态) - const updateToolConfig = useCallback((toolId: string, updates: Partial) => { - setToolConfigs((prev) => ({ - ...prev, - [toolId]: { - ...prev[toolId], - ...updates, - }, - })); - setHasUnsavedChanges(true); - }, []); - - // 保存所有配置 - const saveAllConfigs = useCallback(async () => { - setSavingConfig(true); - try { - // 保存每个工具的配置到 ProxyConfigManager - for (const [toolId, config] of Object.entries(toolConfigs)) { - await updateProxyConfig(toolId as ToolId, config); - } - setHasUnsavedChanges(false); - } catch (error) { - console.error('Failed to save configs:', error); - throw error; - } finally { - setSavingConfig(false); - } - }, [toolConfigs]); - - // 启动代理 - const startProxy = useCallback( - async (toolId: string) => { - setLoadingTools((prev) => new Set(prev).add(toolId)); - try { - await startToolProxy(toolId); - await loadAllProxyStatus(); - } finally { - setLoadingTools((prev) => { - const next = new Set(prev); - next.delete(toolId); - return next; - }); - } - }, - [loadAllProxyStatus], - ); - - // 停止代理 - const stopProxy = useCallback( - async (toolId: string) => { - setLoadingTools((prev) => new Set(prev).add(toolId)); - try { - await stopToolProxy(toolId); - await loadAllProxyStatus(); - } finally { - setLoadingTools((prev) => { - const next = new Set(prev); - next.delete(toolId); - return next; - }); - } - }, - [loadAllProxyStatus], - ); - - return { - allProxyStatus, - toolConfigs, - loadingTools, - savingConfig, - hasUnsavedChanges, - updateToolConfig, - saveAllConfigs, - startProxy, - stopProxy, - loadAllConfigs, - loadAllProxyStatus, - }; -} diff --git a/src/pages/SettingsPage/hooks/useSettingsForm.ts b/src/pages/SettingsPage/hooks/useSettingsForm.ts index e298a35..458876b 100644 --- a/src/pages/SettingsPage/hooks/useSettingsForm.ts +++ b/src/pages/SettingsPage/hooks/useSettingsForm.ts @@ -30,8 +30,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo '*.lan', ]); - // 实验性功能 - 透明代理 - // 状态 const [globalConfig, setGlobalConfig] = useState(initialConfig); const [savingSettings, setSavingSettings] = useState(false); @@ -99,12 +97,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo onConfigChange, ]); - // 生成代理 API Key(已废弃,透明代理功能已移除) - const generateProxyKey = useCallback(() => { - // 功能已移除 - console.warn('generateProxyKey 功能已废弃'); - }, []); - // 测试代理连接 const testProxy = useCallback(async (): Promise<{ success: boolean; @@ -211,7 +203,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo // Actions saveSettings, - generateProxyKey, testProxy, }; } diff --git a/src/pages/StatisticsPage/index.tsx b/src/pages/StatisticsPage/index.tsx deleted file mode 100644 index 2d99377..0000000 --- a/src/pages/StatisticsPage/index.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { BarChart3, Settings as SettingsIcon, RefreshCw, Loader2 } from 'lucide-react'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { PageContainer } from '@/components/layout/PageContainer'; -import { QuotaCard } from '@/components/QuotaCard'; -import { TodayStatsCard } from '@/components/TodayStatsCard'; -import { UsageChart } from '@/components/UsageChart'; -import type { GlobalConfig, UserQuotaResult, UsageStatsResult } from '@/lib/tauri-commands'; - -interface StatisticsPageProps { - globalConfig: GlobalConfig | null; - usageStats: UsageStatsResult | null; - userQuota: UserQuotaResult | null; - statsLoading: boolean; - statsLoadFailed: boolean; - statsError?: string | null; - onLoadStatistics: () => void; -} - -export function StatisticsPage({ - globalConfig, - usageStats, - userQuota, - statsLoading, - statsLoadFailed, - statsError, - onLoadStatistics, -}: StatisticsPageProps) { - const hasCredentials = globalConfig?.user_id && globalConfig?.system_token; - - return ( - -
-

用量统计

-

查看您的 DuckCoding API 使用情况和消费记录

-
- - {!hasCredentials ? ( - - -
- -

需要配置凭证

-

- 请先在全局设置中配置您的用户ID和系统访问令牌 -

- -
-
-
- ) : ( -
- {statsLoadFailed && ( - - -
- 统计数据获取失败 - {statsError || '请检查网络或凭证设置后重试'} -
- -
- )} - -
- -
- - {/* 顶部卡片网格 - 2列 */} -
- - -
- - {/* 用量趋势图 - 全宽 */} - -
- )} -
- ); -}