* feat(providers): add notes field for provider management - Add notes field to Provider model (backend and frontend) - Display notes with higher priority than URL in provider card - Style notes as non-clickable text to differentiate from URLs - Add notes input field in provider form - Add i18n support (zh/en) for notes field * chore: format code and clean up unused props - Run cargo fmt on Rust backend code - Format TypeScript imports and code style - Remove unused appId prop from ProviderPresetSelector - Clean up unused variables in tests - Integrate notes field handling in provider dialogs * feat(deeplink): implement ccswitch:// protocol for provider import Add deep link support to enable one-click provider configuration import via ccswitch:// URLs. Backend: - Implement URL parsing and validation (src-tauri/src/deeplink.rs) - Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs) - Register ccswitch:// protocol in macOS Info.plist - Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs) Frontend: - Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx) - Add API wrapper (src/lib/api/deeplink.ts) - Integrate event listeners in App.tsx Configuration: - Update Tauri config for deep link handling - Add i18n support for Chinese and English - Include test page for deep link validation (deeplink-test.html) Files: 15 changed, 1312 insertions(+) * chore(deeplink): integrate deep link handling into app lifecycle Wire up deep link infrastructure with app initialization and event handling. Backend Integration: - Register deep link module and commands in mod.rs - Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url) - Handle deep links from single instance callback (Windows/Linux CLI) - Handle deep links from macOS system events - Add tauri-plugin-deep-link dependency (Cargo.toml) Frontend Integration: - Listen for deeplink-import/deeplink-error events in App.tsx - Update DeepLinkImportDialog component imports Configuration: - Enable deep link plugin in tauri.conf.json - Update Cargo.lock for new dependencies Localization: - Add Chinese translations for deep link UI (zh.json) - Add English translations for deep link UI (en.json) Files: 9 changed, 359 insertions(+), 18 deletions(-) * refactor(deeplink): enhance Codex provider template generation Align deep link import with UI preset generation logic by: - Adding complete config.toml template matching frontend defaults - Generating safe provider name from sanitized input - Including model_provider, reasoning_effort, and wire_api settings - Removing minimal template that only contained base_url - Cleaning up deprecated test file deeplink-test.html * style: fix clippy uninlined_format_args warnings Apply clippy --fix to use inline format arguments in: - src/mcp.rs (8 fixes) - src/services/env_manager.rs (10 fixes) * style: apply code formatting and cleanup - Format TypeScript files with Prettier (App.tsx, EnvWarningBanner.tsx, formatters.ts) - Organize Rust imports and module order alphabetically - Add newline at end of JSON files (en.json, zh.json) - Update Cargo.lock for dependency changes * feat: add model name configuration support for Codex and fix Gemini model handling - Add visual model name input field for Codex providers - Add model name extraction and update utilities in providerConfigUtils - Implement model name state management in useCodexConfigState hook - Add conditional model field rendering in CodexFormFields (non-official only) - Integrate model name sync with TOML config in ProviderForm - Fix Gemini deeplink model injection bug - Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL - Add test cases for Gemini model injection (with/without model) - All tests passing (9/9) - Fix Gemini model field binding in edit mode - Add geminiModel state to useGeminiConfigState hook - Extract model value during initialization and reset - Sync model field with geminiEnv state to prevent data loss on submit - Fix missing model value display when editing Gemini providers Changes: - 6 files changed, 245 insertions(+), 13 deletions(-)
241 lines
8.5 KiB
Rust
241 lines
8.5 KiB
Rust
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<EnvConflict>,
|
|
}
|
|
|
|
/// Delete environment variables with automatic backup
|
|
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
|
|
// 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<BackupInfo, String> {
|
|
// 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-{timestamp}.json"));
|
|
|
|
// 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<PathBuf, String> {
|
|
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<String> = 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());
|
|
}
|
|
}
|