diff --git a/package.json b/package.json index 145cf56..1e142ac 100644 --- a/package.json +++ b/package.json @@ -77,5 +77,6 @@ "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", "zod": "^4.1.12" - } + }, + "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d5fb415..0663cc8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -625,6 +625,7 @@ dependencies = [ "tokio", "toml 0.8.2", "toml_edit 0.22.27", + "winreg 0.52.0", "zip 2.4.2", ] @@ -1109,7 +1110,7 @@ dependencies = [ "rustc_version", "toml 0.9.7", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -6285,6 +6286,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 61e8174..ea5f9ca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,9 @@ tempfile = "3" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.52" + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.5" objc2-app-kit = { version = "0.2", features = ["NSColor"] } diff --git a/src-tauri/src/commands/env.rs b/src-tauri/src/commands/env.rs new file mode 100644 index 0000000..e1b31a8 --- /dev/null +++ b/src-tauri/src/commands/env.rs @@ -0,0 +1,20 @@ +use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict}; +use crate::services::env_manager::{delete_env_vars as delete_vars, restore_from_backup, BackupInfo}; + +/// Check environment variable conflicts for a specific app +#[tauri::command] +pub fn check_env_conflicts(app: String) -> Result, String> { + check_conflicts(&app) +} + +/// Delete environment variables with backup +#[tauri::command] +pub fn delete_env_vars(conflicts: Vec) -> Result { + delete_vars(conflicts) +} + +/// Restore environment variables from backup file +#[tauri::command] +pub fn restore_env_backup(backup_path: String) -> Result<(), String> { + restore_from_backup(backup_path) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 17b671e..837d9af 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod config; +mod env; mod import_export; mod mcp; mod misc; @@ -11,6 +12,7 @@ mod settings; pub mod skill; pub use config::*; +pub use env::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index de53b1a..3ab7774 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -586,6 +586,10 @@ pub fn run() { commands::open_file_dialog, commands::sync_current_providers_live, update_tray_menu, + // Environment variable management + commands::check_env_conflicts, + commands::delete_env_vars, + commands::restore_env_backup, // Skill management commands::get_skills, commands::install_skill, diff --git a/src-tauri/src/services/env_checker.rs b/src-tauri/src/services/env_checker.rs new file mode 100644 index 0000000..2a437ce --- /dev/null +++ b/src-tauri/src/services/env_checker.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; +use std::fs; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvConflict { + pub var_name: String, + pub var_value: String, + pub source_type: String, // "system" | "file" + pub source_path: String, // Registry path or file path +} + +#[cfg(target_os = "windows")] +use winreg::enums::*; +#[cfg(target_os = "windows")] +use winreg::RegKey; + +/// Check environment variables for conflicts +pub fn check_env_conflicts(app: &str) -> Result, String> { + let keywords = get_keywords_for_app(app); + let mut conflicts = Vec::new(); + + // Check system environment variables + conflicts.extend(check_system_env(&keywords)?); + + // Check shell configuration files (Unix only) + #[cfg(not(target_os = "windows"))] + conflicts.extend(check_shell_configs(&keywords)?); + + Ok(conflicts) +} + +/// Get relevant keywords for each app +fn get_keywords_for_app(app: &str) -> Vec<&str> { + match app.to_lowercase().as_str() { + "claude" => vec!["ANTHROPIC"], + "codex" => vec!["OPENAI"], + _ => vec![], + } +} + +/// Check system environment variables (Windows Registry or Unix env) +#[cfg(target_os = "windows")] +fn check_system_env(keywords: &[&str]) -> Result, String> { + let mut conflicts = Vec::new(); + + // Check HKEY_CURRENT_USER\Environment + if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") { + for (name, value) in hkcu.enum_values().filter_map(Result::ok) { + if keywords.iter().any(|k| name.to_uppercase().contains(k)) { + if let Ok(val) = value.to_string() { + conflicts.push(EnvConflict { + var_name: name.clone(), + var_value: val, + source_type: "system".to_string(), + source_path: "HKEY_CURRENT_USER\\Environment".to_string(), + }); + } + } + } + } + + // Check HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment + if let Ok(hklm) = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment") + { + for (name, value) in hklm.enum_values().filter_map(Result::ok) { + if keywords.iter().any(|k| name.to_uppercase().contains(k)) { + if let Ok(val) = value.to_string() { + conflicts.push(EnvConflict { + var_name: name.clone(), + var_value: val, + source_type: "system".to_string(), + source_path: "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment".to_string(), + }); + } + } + } + } + + Ok(conflicts) +} + +#[cfg(not(target_os = "windows"))] +fn check_system_env(keywords: &[&str]) -> Result, String> { + let mut conflicts = Vec::new(); + + // Check current process environment + for (key, value) in std::env::vars() { + if keywords.iter().any(|k| key.to_uppercase().contains(k)) { + conflicts.push(EnvConflict { + var_name: key, + var_value: value, + source_type: "system".to_string(), + source_path: "Process Environment".to_string(), + }); + } + } + + Ok(conflicts) +} + +/// Check shell configuration files for environment variable exports (Unix only) +#[cfg(not(target_os = "windows"))] +fn check_shell_configs(keywords: &[&str]) -> Result, String> { + let mut conflicts = Vec::new(); + + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + let config_files = vec![ + format!("{}/.bashrc", home), + format!("{}/.bash_profile", home), + format!("{}/.zshrc", home), + format!("{}/.zprofile", home), + format!("{}/.profile", home), + "/etc/profile".to_string(), + "/etc/bashrc".to_string(), + ]; + + for file_path in config_files { + if let Ok(content) = fs::read_to_string(&file_path) { + // Parse lines for export statements + for (line_num, line) in content.lines().enumerate() { + let trimmed = line.trim(); + + // Match patterns like: export VAR=value or VAR=value + if trimmed.starts_with("export ") || (!trimmed.starts_with('#') && trimmed.contains('=')) { + let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed); + + if let Some(eq_pos) = export_line.find('=') { + let var_name = export_line[..eq_pos].trim(); + let var_value = export_line[eq_pos + 1..].trim(); + + // Check if variable name contains any keyword + if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) { + conflicts.push(EnvConflict { + var_name: var_name.to_string(), + var_value: var_value.trim_matches('"').trim_matches('\'').to_string(), + source_type: "file".to_string(), + source_path: format!("{}:{}", file_path, line_num + 1), + }); + } + } + } + } + } + } + + Ok(conflicts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_keywords() { + assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]); + assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]); + assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new()); + } +} diff --git a/src-tauri/src/services/env_manager.rs b/src-tauri/src/services/env_manager.rs new file mode 100644 index 0000000..ffb8aa2 --- /dev/null +++ b/src-tauri/src/services/env_manager.rs @@ -0,0 +1,236 @@ +use super::env_checker::EnvConflict; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[cfg(target_os = "windows")] +use winreg::enums::*; +#[cfg(target_os = "windows")] +use winreg::RegKey; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupInfo { + pub backup_path: String, + pub timestamp: String, + pub conflicts: Vec, +} + +/// Delete environment variables with automatic backup +pub fn delete_env_vars(conflicts: Vec) -> Result { + // Step 1: Create backup + let backup_info = create_backup(&conflicts)?; + + // Step 2: Delete variables + for conflict in &conflicts { + match delete_single_env(conflict) { + Ok(_) => {} + Err(e) => { + // If deletion fails, we keep the backup but return error + return Err(format!( + "删除环境变量失败: {}. 备份已保存到: {}", + e, backup_info.backup_path + )); + } + } + } + + Ok(backup_info) +} + +/// Create backup file before deletion +fn create_backup(conflicts: &[EnvConflict]) -> Result { + // Get backup directory + let backup_dir = get_backup_dir()?; + fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {}", e))?; + + // Generate backup file name with timestamp + let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); + let backup_file = backup_dir.join(format!("env-backup-{}.json", timestamp)); + + // Create backup data + let backup_info = BackupInfo { + backup_path: backup_file.to_string_lossy().to_string(), + timestamp: timestamp.clone(), + conflicts: conflicts.to_vec(), + }; + + // Write backup file + let json = serde_json::to_string_pretty(&backup_info) + .map_err(|e| format!("序列化备份数据失败: {}", e))?; + + fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {}", e))?; + + Ok(backup_info) +} + +/// Get backup directory path +fn get_backup_dir() -> Result { + let home = dirs::home_dir().ok_or("无法获取用户主目录")?; + Ok(home.join(".cc-switch").join("backups")) +} + +/// Delete a single environment variable +#[cfg(target_os = "windows")] +fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> { + match conflict.source_type.as_str() { + "system" => { + if conflict.source_path.contains("HKEY_CURRENT_USER") { + let hkcu = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags("Environment", KEY_ALL_ACCESS) + .map_err(|e| format!("打开注册表失败: {}", e))?; + + hkcu.delete_value(&conflict.var_name) + .map_err(|e| format!("删除注册表项失败: {}", e))?; + } else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags( + "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + KEY_ALL_ACCESS, + ) + .map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?; + + hklm.delete_value(&conflict.var_name) + .map_err(|e| format!("删除系统注册表项失败: {}", e))?; + } + Ok(()) + } + "file" => Err("Windows 系统不应该有文件类型的环境变量".to_string()), + _ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)), + } +} + +#[cfg(not(target_os = "windows"))] +fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> { + match conflict.source_type.as_str() { + "file" => { + // Parse file path and line number from source_path (format: "path:line") + let parts: Vec<&str> = conflict.source_path.split(':').collect(); + if parts.len() < 2 { + return Err("无效的文件路径格式".to_string()); + } + + let file_path = parts[0]; + + // Read file content + let content = fs::read_to_string(file_path) + .map_err(|e| format!("读取文件失败 {}: {}", file_path, e))?; + + // Filter out the line containing the environment variable + let new_content: Vec = content + .lines() + .filter(|line| { + let trimmed = line.trim(); + let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed); + + // Check if this line sets the target variable + if let Some(eq_pos) = export_line.find('=') { + let var_name = export_line[..eq_pos].trim(); + var_name != conflict.var_name + } else { + true + } + }) + .map(|s| s.to_string()) + .collect(); + + // Write back to file + fs::write(file_path, new_content.join("\n")) + .map_err(|e| format!("写入文件失败 {}: {}", file_path, e))?; + + Ok(()) + } + "system" => { + // On Unix, we can't directly delete process environment variables + Ok(()) + } + _ => Err(format!("未知的环境变量来源类型: {}", conflict.source_type)), + } +} + +/// Restore environment variables from backup +pub fn restore_from_backup(backup_path: String) -> Result<(), String> { + // Read backup file + let content = + fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {}", e))?; + + let backup_info: BackupInfo = serde_json::from_str(&content) + .map_err(|e| format!("解析备份文件失败: {}", e))?; + + // Restore each variable + for conflict in &backup_info.conflicts { + restore_single_env(conflict)?; + } + + Ok(()) +} + +/// Restore a single environment variable +#[cfg(target_os = "windows")] +fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> { + match conflict.source_type.as_str() { + "system" => { + if conflict.source_path.contains("HKEY_CURRENT_USER") { + let (hkcu, _) = RegKey::predef(HKEY_CURRENT_USER) + .create_subkey("Environment") + .map_err(|e| format!("打开注册表失败: {}", e))?; + + hkcu.set_value(&conflict.var_name, &conflict.var_value) + .map_err(|e| format!("恢复注册表项失败: {}", e))?; + } else if conflict.source_path.contains("HKEY_LOCAL_MACHINE") { + let (hklm, _) = RegKey::predef(HKEY_LOCAL_MACHINE) + .create_subkey( + "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + ) + .map_err(|e| format!("打开系统注册表失败 (需要管理员权限): {}", e))?; + + hklm.set_value(&conflict.var_name, &conflict.var_value) + .map_err(|e| format!("恢复系统注册表项失败: {}", e))?; + } + Ok(()) + } + _ => Err(format!("无法恢复类型为 {} 的环境变量", conflict.source_type)), + } +} + +#[cfg(not(target_os = "windows"))] +fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> { + match conflict.source_type.as_str() { + "file" => { + // Parse file path from source_path + let parts: Vec<&str> = conflict.source_path.split(':').collect(); + if parts.is_empty() { + return Err("无效的文件路径格式".to_string()); + } + + let file_path = parts[0]; + + // Read file content + let mut content = fs::read_to_string(file_path) + .map_err(|e| format!("读取文件失败 {}: {}", file_path, e))?; + + // Append the environment variable line + let export_line = format!("\nexport {}={}", conflict.var_name, conflict.var_value); + content.push_str(&export_line); + + // Write back to file + fs::write(file_path, content) + .map_err(|e| format!("写入文件失败 {}: {}", file_path, e))?; + + Ok(()) + } + _ => Err(format!("无法恢复类型为 {} 的环境变量", conflict.source_type)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backup_dir_creation() { + let backup_dir = get_backup_dir(); + assert!(backup_dir.is_ok()); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index c1908a0..747a086 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,4 +1,6 @@ pub mod config; +pub mod env_checker; +pub mod env_manager; pub mod mcp; pub mod prompt; pub mod provider; diff --git a/src/App.tsx b/src/App.tsx index ee9f908..1464c43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Plus, Settings, Edit3 } from "lucide-react"; import type { Provider } from "@/types"; +import type { EnvConflict } from "@/types/env"; import { useProvidersQuery } from "@/lib/query"; import { providersApi, @@ -10,6 +11,7 @@ import { type AppId, type ProviderSwitchEvent, } from "@/lib/api"; +import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; import { extractErrorMessage } from "@/utils/errorUtils"; import { AppSwitcher } from "@/components/AppSwitcher"; @@ -19,6 +21,7 @@ import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { UpdateBadge } from "@/components/UpdateBadge"; +import { EnvWarningBanner } from "@/components/env/EnvWarningBanner"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; @@ -45,6 +48,8 @@ function App() { const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); + const [envConflicts, setEnvConflicts] = useState([]); + const [showEnvBanner, setShowEnvBanner] = useState(false); const { data, isLoading, refetch } = useProvidersQuery(activeApp); const providers = useMemo(() => data?.providers ?? {}, [data]); @@ -83,6 +88,52 @@ function App() { }; }, [activeApp, refetch]); + // 应用启动时检测所有应用的环境变量冲突 + useEffect(() => { + const checkEnvOnStartup = async () => { + try { + const allConflicts = await checkAllEnvConflicts(); + const flatConflicts = Object.values(allConflicts).flat(); + + if (flatConflicts.length > 0) { + setEnvConflicts(flatConflicts); + setShowEnvBanner(true); + } + } catch (error) { + console.error("[App] Failed to check environment conflicts on startup:", error); + } + }; + + checkEnvOnStartup(); + }, []); + + // 切换应用时检测当前应用的环境变量冲突 + useEffect(() => { + const checkEnvOnSwitch = async () => { + try { + const conflicts = await checkEnvConflicts(activeApp); + + if (conflicts.length > 0) { + // 合并新检测到的冲突 + setEnvConflicts((prev) => { + const existingKeys = new Set( + prev.map((c) => `${c.varName}:${c.sourcePath}`) + ); + const newConflicts = conflicts.filter( + (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`) + ); + return [...prev, ...newConflicts]; + }); + setShowEnvBanner(true); + } + } catch (error) { + console.error("[App] Failed to check environment conflicts on app switch:", error); + } + }; + + checkEnvOnSwitch(); + }, [activeApp]); + // 打开网站链接 const handleOpenWebsite = async (url: string) => { try { @@ -173,6 +224,27 @@ function App() { return (
+ {/* 环境变量警告横幅 */} + {showEnvBanner && envConflicts.length > 0 && ( + setShowEnvBanner(false)} + onDeleted={async () => { + // 删除后重新检测 + try { + const allConflicts = await checkAllEnvConflicts(); + const flatConflicts = Object.values(allConflicts).flat(); + setEnvConflicts(flatConflicts); + if (flatConflicts.length === 0) { + setShowEnvBanner(false); + } + } catch (error) { + console.error("[App] Failed to re-check conflicts after deletion:", error); + } + }} + /> + )} +
diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx new file mode 100644 index 0000000..3ae21d0 --- /dev/null +++ b/src/components/env/EnvWarningBanner.tsx @@ -0,0 +1,271 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, ChevronDown, ChevronUp, X, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { EnvConflict } from "@/types/env"; +import { deleteEnvVars } from "@/lib/api/env"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface EnvWarningBannerProps { + conflicts: EnvConflict[]; + onDismiss: () => void; + onDeleted: () => void; +} + +export function EnvWarningBanner({ + conflicts, + onDismiss, + onDeleted, +}: EnvWarningBannerProps) { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = useState(false); + const [selectedConflicts, setSelectedConflicts] = useState>( + new Set(), + ); + const [isDeleting, setIsDeleting] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + + if (conflicts.length === 0) { + return null; + } + + const toggleSelection = (key: string) => { + const newSelection = new Set(selectedConflicts); + if (newSelection.has(key)) { + newSelection.delete(key); + } else { + newSelection.add(key); + } + setSelectedConflicts(newSelection); + }; + + const toggleSelectAll = () => { + if (selectedConflicts.size === conflicts.length) { + setSelectedConflicts(new Set()); + } else { + setSelectedConflicts( + new Set(conflicts.map((c) => `${c.varName}:${c.sourcePath}`)), + ); + } + }; + + const handleDelete = async () => { + setShowConfirmDialog(false); + setIsDeleting(true); + + try { + const conflictsToDelete = conflicts.filter((c) => + selectedConflicts.has(`${c.varName}:${c.sourcePath}`), + ); + + if (conflictsToDelete.length === 0) { + toast.warning(t("env.error.noSelection")); + return; + } + + const backupInfo = await deleteEnvVars(conflictsToDelete); + + toast.success(t("env.delete.success"), { + description: t("env.backup.location", { + path: backupInfo.backupPath, + }), + duration: 5000, + }); + + // 清空选择并通知父组件 + setSelectedConflicts(new Set()); + onDeleted(); + } catch (error) { + console.error("删除环境变量失败:", error); + toast.error(t("env.delete.error"), { + description: String(error), + }); + } finally { + setIsDeleting(false); + } + }; + + const getSourceDescription = (conflict: EnvConflict): string => { + if (conflict.sourceType === "system") { + if (conflict.sourcePath.includes("HKEY_CURRENT_USER")) { + return t("env.source.userRegistry"); + } else if (conflict.sourcePath.includes("HKEY_LOCAL_MACHINE")) { + return t("env.source.systemRegistry"); + } else { + return t("env.source.systemEnv"); + } + } else { + return conflict.sourcePath; + } + }; + + return ( + <> +
+
+
+ + +
+
+
+

+ {t("env.warning.title")} +

+

+ {t("env.warning.description", { count: conflicts.length })} +

+
+ +
+ + + +
+
+ + {isExpanded && ( +
+
+ + +
+ +
+ {conflicts.map((conflict) => { + const key = `${conflict.varName}:${conflict.sourcePath}`; + return ( +
+ toggleSelection(key)} + /> + +
+ +

+ {t("env.field.value")}: {conflict.varValue} +

+

+ {t("env.field.source")}: {getSourceDescription(conflict)} +

+
+
+ ); + })} +
+ +
+ + + +
+
+ )} +
+
+
+
+ + + + + + + {t("env.confirm.title")} + + +

{t("env.confirm.message", { count: selectedConflicts.size })}

+

+ {t("env.confirm.backupNotice")} +

+
+
+ + + + +
+
+ + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 19abdac..15b6612 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -608,6 +608,43 @@ "deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?" } }, + "env": { + "warning": { + "title": "Environment Variable Conflicts Detected", + "description": "Found {{count}} environment variables that may override your configuration" + }, + "actions": { + "expand": "View Details", + "collapse": "Collapse", + "selectAll": "Select All", + "clearSelection": "Clear Selection", + "deleteSelected": "Delete Selected ({{count}})", + "deleting": "Deleting..." + }, + "field": { + "value": "Value", + "source": "Source" + }, + "source": { + "userRegistry": "User Environment Variable (Registry)", + "systemRegistry": "System Environment Variable (Registry)", + "systemEnv": "System Environment Variable" + }, + "delete": { + "success": "Environment variables deleted successfully", + "error": "Failed to delete environment variables" + }, + "backup": { + "location": "Backup location: {{path}}" + }, + "confirm": { + "title": "Confirm Delete Environment Variables", + "message": "Are you sure you want to delete {{count}} environment variable(s)?", + "backupNotice": "A backup will be created automatically before deletion. You can restore it later. Changes take effect after restarting the application or terminal.", + "confirm": "Confirm Delete" + }, + "error": { + "noSelection": "Please select environment variables to delete" "skills": { "manage": "Skills", "title": "Claude Skills Management", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 24f0cfb..7d1fced 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -608,6 +608,43 @@ "deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?" } }, + "env": { + "warning": { + "title": "检测到系统环境变量冲突", + "description": "发现 {{count}} 个环境变量可能会覆盖您的配置" + }, + "actions": { + "expand": "查看详情", + "collapse": "收起", + "selectAll": "全选", + "clearSelection": "取消选择", + "deleteSelected": "删除选中 ({{count}})", + "deleting": "删除中..." + }, + "field": { + "value": "值", + "source": "来源" + }, + "source": { + "userRegistry": "用户环境变量 (注册表)", + "systemRegistry": "系统环境变量 (注册表)", + "systemEnv": "系统环境变量" + }, + "delete": { + "success": "环境变量已成功删除", + "error": "删除环境变量失败" + }, + "backup": { + "location": "备份位置: {{path}}" + }, + "confirm": { + "title": "确认删除环境变量", + "message": "确定要删除 {{count}} 个环境变量吗?", + "backupNotice": "删除前将自动备份,您可以稍后恢复。删除后需要重启应用或终端才能生效。", + "confirm": "确认删除" + }, + "error": { + "noSelection": "请选择要删除的环境变量" "skills": { "manage": "Skills", "title": "Claude Skills 管理", diff --git a/src/lib/api/env.ts b/src/lib/api/env.ts new file mode 100644 index 0000000..a912f78 --- /dev/null +++ b/src/lib/api/env.ts @@ -0,0 +1,60 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { EnvConflict, BackupInfo } from "@/types/env"; + +/** + * 环境变量管理 API + */ + +/** + * 检查指定应用的环境变量冲突 + * @param appType 应用类型 ("claude" | "codex" | "gemini") + * @returns 环境变量冲突列表 + */ +export async function checkEnvConflicts( + appType: string, +): Promise { + return invoke("check_env_conflicts", { app: appType }); +} + +/** + * 删除指定的环境变量 (会自动备份) + * @param conflicts 要删除的环境变量冲突列表 + * @returns 备份信息 + */ +export async function deleteEnvVars( + conflicts: EnvConflict[], +): Promise { + return invoke("delete_env_vars", { conflicts }); +} + +/** + * 从备份文件恢复环境变量 + * @param backupPath 备份文件路径 + */ +export async function restoreEnvBackup(backupPath: string): Promise { + return invoke("restore_env_backup", { backupPath }); +} + +/** + * 检查所有应用的环境变量冲突 + * @returns 按应用类型分组的环境变量冲突 + */ +export async function checkAllEnvConflicts(): Promise< + Record +> { + const apps = ["claude", "codex", "gemini"]; + const results: Record = {}; + + await Promise.all( + apps.map(async (app) => { + try { + results[app] = await checkEnvConflicts(app); + } catch (error) { + console.error(`检查 ${app} 环境变量失败:`, error); + results[app] = []; + } + }), + ); + + return results; +} diff --git a/src/types/env.ts b/src/types/env.ts new file mode 100644 index 0000000..17cd3af --- /dev/null +++ b/src/types/env.ts @@ -0,0 +1,29 @@ +/** + * 环境变量冲突检测相关类型定义 + */ + +/** + * 环境变量冲突信息 + */ +export interface EnvConflict { + /** 环境变量名称 */ + varName: string; + /** 环境变量的值 */ + varValue: string; + /** 来源类型: "system" 表示系统环境变量, "file" 表示配置文件 */ + sourceType: "system" | "file"; + /** 来源路径 (注册表路径或文件路径:行号) */ + sourcePath: string; +} + +/** + * 备份信息 + */ +export interface BackupInfo { + /** 备份文件路径 */ + backupPath: string; + /** 备份时间戳 */ + timestamp: string; + /** 被备份的环境变量冲突列表 */ + conflicts: EnvConflict[]; +}