添加Claude和Codex环境变量检查 (#242)

* feat(env): add environment variable conflict detection and management

实现了系统环境变量冲突检测与管理功能:

核心功能:
- 自动检测会影响 Claude/Codex 的系统环境变量
- 支持 Windows 注册表和 Unix shell 配置文件检测
- 提供可视化的环境变量冲突警告横幅
- 支持批量选择和删除环境变量
- 删除前自动备份,支持后续恢复

技术实现:
- Rust 后端: 跨平台环境变量检测与管理
- React 前端: EnvWarningBanner 组件交互界面
- 国际化支持: 中英文界面
- 类型安全: 完整的 TypeScript 类型定义

* refactor(env): remove unused imports and function

Remove unused HashMap and PathBuf imports, and delete the unused get_source_description function to clean up the code.
This commit is contained in:
冰子
2025-11-18 23:44:44 +08:00
committed by GitHub
parent ec303544ca
commit b9412ece0b
15 changed files with 948 additions and 2 deletions

View File

@@ -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<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
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 (
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
onDismiss={() => 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);
}
}}
/>
)}
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1">

271
src/components/env/EnvWarningBanner.tsx vendored Normal file
View File

@@ -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<Set<string>>(
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 (
<>
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/50">
<div className="container mx-auto px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
{t("env.warning.title")}
</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-200 mt-0.5">
{t("env.warning.description", { count: conflicts.length })}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50"
>
{isExpanded ? (
<>
{t("env.actions.collapse")}
<ChevronUp className="h-4 w-4 ml-1" />
</>
) : (
<>
{t("env.actions.expand")}
<ChevronDown className="h-4 w-4 ml-1" />
</>
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onDismiss}
className="text-yellow-900 dark:text-yellow-100 hover:bg-yellow-100 dark:hover:bg-yellow-900/50"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{isExpanded && (
<div className="mt-4 space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-900/50">
<Checkbox
id="select-all"
checked={selectedConflicts.size === conflicts.length}
onCheckedChange={toggleSelectAll}
/>
<label
htmlFor="select-all"
className="text-sm font-medium text-yellow-900 dark:text-yellow-100 cursor-pointer"
>
{t("env.actions.selectAll")}
</label>
</div>
<div className="max-h-96 overflow-y-auto space-y-2">
{conflicts.map((conflict) => {
const key = `${conflict.varName}:${conflict.sourcePath}`;
return (
<div
key={key}
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-900 rounded-md border border-yellow-200 dark:border-yellow-900/50"
>
<Checkbox
id={key}
checked={selectedConflicts.has(key)}
onCheckedChange={() => toggleSelection(key)}
/>
<div className="flex-1 min-w-0">
<label
htmlFor={key}
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
{conflict.varName}
</label>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
{t("env.field.value")}: {conflict.varValue}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{t("env.field.source")}: {getSourceDescription(conflict)}
</p>
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-yellow-200 dark:border-yellow-900/50">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedConflicts(new Set())}
disabled={selectedConflicts.size === 0}
className="text-yellow-900 dark:text-yellow-100 border-yellow-300 dark:border-yellow-800"
>
{t("env.actions.clearSelection")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setShowConfirmDialog(true)}
disabled={selectedConflicts.size === 0 || isDeleting}
className="gap-1"
>
<Trash2 className="h-4 w-4" />
{isDeleting
? t("env.actions.deleting")
: t("env.actions.deleteSelected", {
count: selectedConflicts.size,
})}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
{t("env.confirm.title")}
</DialogTitle>
<DialogDescription className="space-y-2">
<p>{t("env.confirm.message", { count: selectedConflicts.size })}</p>
<p className="text-sm text-muted-foreground">
{t("env.confirm.backupNotice")}
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirmDialog(false)}
>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t("env.confirm.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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",

View File

@@ -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 管理",

60
src/lib/api/env.ts Normal file
View File

@@ -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<EnvConflict[]> {
return invoke<EnvConflict[]>("check_env_conflicts", { app: appType });
}
/**
* 删除指定的环境变量 (会自动备份)
* @param conflicts 要删除的环境变量冲突列表
* @returns 备份信息
*/
export async function deleteEnvVars(
conflicts: EnvConflict[],
): Promise<BackupInfo> {
return invoke<BackupInfo>("delete_env_vars", { conflicts });
}
/**
* 从备份文件恢复环境变量
* @param backupPath 备份文件路径
*/
export async function restoreEnvBackup(backupPath: string): Promise<void> {
return invoke<void>("restore_env_backup", { backupPath });
}
/**
* 检查所有应用的环境变量冲突
* @returns 按应用类型分组的环境变量冲突
*/
export async function checkAllEnvConflicts(): Promise<
Record<string, EnvConflict[]>
> {
const apps = ["claude", "codex", "gemini"];
const results: Record<string, EnvConflict[]> = {};
await Promise.all(
apps.map(async (app) => {
try {
results[app] = await checkEnvConflicts(app);
} catch (error) {
console.error(`检查 ${app} 环境变量失败:`, error);
results[app] = [];
}
}),
);
return results;
}

29
src/types/env.ts Normal file
View File

@@ -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[];
}