Files
cc-switch/src-tauri/src/services/env_manager.rs
YoVinchen 3d69da5b66 feat: add model configuration support and fix Gemini deeplink bug (#251)
* 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(-)
2025-11-19 09:03:18 +08:00

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());
}
}