use super::*; pub(crate) fn validate_core_api_key(api_key: &str) -> Result<(), String> { if api_key.is_empty() { return Err("鉴权密钥不能为空".to_string()); } if !api_key.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { return Err("鉴权密钥只能包含 ASCII 可见字符,且不能包含空格".to_string()); } if is_example_core_api_key(api_key) { return Err("不能使用内核模板里的示例鉴权密钥".to_string()); } Ok(()) } pub(crate) fn validate_api_key_remark(remark: &str) -> Result<(), String> { if remark.chars().count() > 80 { return Err("密钥备注不能超过 80 个字符".to_string()); } if remark.chars().any(char::is_control) { return Err("密钥备注不能包含换行或控制字符".to_string()); } Ok(()) } pub(crate) fn validate_api_access_provider_section(section: &str) -> Result<(), String> { if matches!( section, "gemini-api-key" | "codex-api-key" | "claude-api-key" | "openai-compatibility" ) { Ok(()) } else { Err("API 接入类型无效".to_string()) } } pub(crate) fn api_access_key_hash(value: &str) -> Option { let value = value.trim(); (!value.is_empty()).then(|| sha256_bytes(value.as_bytes())) } pub(crate) fn usage_provider_section(provider: &str) -> Option<&'static str> { match provider.trim().to_ascii_lowercase().as_str() { "codex" => Some("codex-api-key"), "claude" => Some("claude-api-key"), "gemini" | "aistudio" => Some("gemini-api-key"), "openai" | "openai-compatibility" => Some("openai-compatibility"), _ => None, } } impl GuiConfigFile { pub(crate) fn api_access_remark_for_source( &self, provider: &str, source: &str, ) -> Option<&str> { let hash = api_access_key_hash(source)?; let preferred_section = usage_provider_section(provider); self.api_access_remarks .iter() .find(|entry| { preferred_section == Some(entry.provider_section.as_str()) && entry.api_key_hash == hash && !entry.remark.is_empty() }) .or_else(|| { self.api_access_remarks .iter() .find(|entry| entry.api_key_hash == hash && !entry.remark.is_empty()) }) .map(|entry| entry.remark.as_str()) } } pub(crate) fn validate_management_secret_key(secret_key: &str) -> Result<(), String> { if secret_key.chars().count() > 512 { return Err("管理密钥不能超过 512 个字符".to_string()); } if secret_key.chars().any(char::is_control) { return Err("管理密钥不能包含控制字符".to_string()); } Ok(()) } pub(crate) fn validate_strong_management_secret_key(secret_key: &str) -> Result<(), String> { validate_management_secret_key(secret_key)?; if secret_key.trim().is_empty() { return Err("WebUI 密钥不能为空".to_string()); } if secret_key.trim() == LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY { return Err("不能继续使用旧版默认 WebUI 密钥 123456".to_string()); } if is_hashed_management_secret_key(secret_key) { return Err("GUI 配置必须保存可用于管理接口认证的明文 WebUI 密钥".to_string()); } Ok(()) } pub(crate) fn generate_management_secret_key() -> Result { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; let mut random = [0_u8; 32]; getrandom::fill(&mut random).map_err(|error| format!("生成 WebUI 安全密钥失败: {error}"))?; Ok(format!("wui-Aa9_{}", URL_SAFE_NO_PAD.encode(random))) } pub(crate) fn management_secret_requires_rotation(secret_key: &str) -> bool { let secret_key = secret_key.trim(); secret_key.is_empty() || secret_key == LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY || is_hashed_management_secret_key(secret_key) } pub(crate) fn ensure_strong_management_secret(config: &mut GuiConfigFile) -> Result { if !management_secret_requires_rotation(&config.management_secret_key) { return Ok(false); } config.management_secret_key = generate_management_secret_key()?; Ok(true) } pub(crate) fn normalize_management_secret_key(secret_key: String) -> Result { let secret_key = secret_key.trim().to_string(); validate_strong_management_secret_key(&secret_key)?; Ok(secret_key) } pub(crate) fn is_example_core_api_key(api_key: &str) -> bool { let value = api_key.trim(); value == "your-api-key" || value.starts_with("your-api-key-") } pub(crate) fn is_hashed_management_secret_key(secret_key: &str) -> bool { let value = secret_key.trim(); value.starts_with("$2a$") || value.starts_with("$2b$") || value.starts_with("$2y$") || value.starts_with("$argon2") || value.starts_with("$scrypt$") || value.starts_with("bcrypt:") || value.starts_with("argon2:") || value.starts_with("argon2id:") || value.starts_with("sha256:") || value.starts_with("sha512:") } pub(crate) fn validate_routing_strategy(strategy: &str) -> Result<(), String> { if matches!(strategy, "round-robin" | "fill-first") { return Ok(()); } Err("路由策略只支持 round-robin 或 fill-first".to_string()) } pub(crate) fn normalize_optional_config_string( value: String, field_name: &str, ) -> Result { let value = value.trim().to_string(); if value.chars().any(char::is_control) { return Err(format!("{field_name} 不能包含控制字符")); } Ok(value) } pub(crate) fn normalize_core_tls_settings( settings: CoreTlsSettings, ) -> Result { let cert = normalize_optional_config_string(settings.cert, "TLS certificate path")?; let key = normalize_optional_config_string(settings.key, "TLS private key path")?; if settings.enabled && (cert.is_empty() || key.is_empty()) { return Err( "TLS is enabled, so both the certificate and private key paths are required" .to_string(), ); } Ok(CoreTlsSettings { enabled: settings.enabled, cert, key, }) } #[cfg(test)] pub(crate) fn core_loopback_origin(port: u16, tls_enabled: bool) -> String { core_origin("127.0.0.1", port, tls_enabled) } pub(crate) fn core_connect_host(listen_host: &str) -> String { let host = listen_host .trim() .strip_prefix('[') .and_then(|value| value.strip_suffix(']')) .unwrap_or_else(|| listen_host.trim()); match host { "" | "0.0.0.0" => "127.0.0.1".to_string(), "::" => "::1".to_string(), _ => host.to_string(), } } pub(crate) fn core_origin(listen_host: &str, port: u16, tls_enabled: bool) -> String { let scheme = if tls_enabled { "https" } else { "http" }; let host = core_connect_host(listen_host); let url_host = if host.contains(':') { format!("[{host}]") } else { host }; format!("{scheme}://{url_host}:{port}") } pub(crate) fn managed_core_tls_enabled() -> bool { #[cfg(test)] { false } #[cfg(not(test))] { current_core_tls_settings() .map(|settings| settings.enabled) .unwrap_or(false) } } pub(crate) fn managed_core_loopback_origin(port: u16) -> String { #[cfg(test)] { core_loopback_origin(port, false) } #[cfg(not(test))] { let host = read_installed_core_config_settings() .map(|settings| settings.host) .unwrap_or_else(|_| "127.0.0.1".to_string()); core_origin(&host, port, managed_core_tls_enabled()) } } pub(crate) fn config_update_error_with_rollback( error: String, rollback_error: Option, ) -> String { match rollback_error { Some(rollback_error) => format!("{error};回滚内核配置也失败: {rollback_error}"), None => error, } } pub(crate) fn merge_core_config_yaml( template: &str, current: Option<&str>, config: &GuiConfigFile, ) -> Result { let base = match current { Some(current) => current.to_string(), None => merge_core_config_fields(template, None)?, }; apply_gui_managed_settings(&base, config) } pub(crate) fn merge_core_config_fields( template: &str, current: Option<&str>, ) -> Result { let current_value = current .map(|current| { let current = serde_norway::from_str::(current) .map_err(|err| format!("解析现有内核配置失败,为避免配置丢失已停止写入: {err}"))?; if !current.is_mapping() { return Err("现有内核配置根节点必须是 YAML 映射,已停止写入".to_string()); } Ok(current) }) .transpose()?; merge_core_config_value(template, current_value) } pub(crate) fn merge_core_config_fields_tolerant( template: &str, current: &str, ) -> Result { let current = match serde_norway::from_str::(current) { Ok(current) if current.is_mapping() => current, _ => recover_parseable_top_level_yaml_fields(current), }; merge_core_config_value(template, Some(current)) } pub(crate) fn merge_core_config_value( template: &str, current: Option, ) -> Result { let template_value = serde_norway::from_str::(template) .map_err(|err| format!("解析内核配置模板失败: {err}"))?; if !template_value.is_mapping() { return Err("内核配置模板根节点必须是 YAML 映射".to_string()); } let mut merged = template_value.clone(); if let Some(current) = current { merge_yaml_values(&mut merged, current); } let rendered = render_yaml_value_changes(template, &template_value, &merged)?; let rendered_value = serde_norway::from_str::(&rendered) .map_err(|err| format!("验证迁移后的内核配置失败: {err}"))?; if !rendered_value.is_mapping() { return Err("迁移后的内核配置根节点必须是 YAML 映射".to_string()); } Ok(rendered) } pub(crate) fn recover_parseable_top_level_yaml_fields(content: &str) -> serde_norway::Value { let lines = yaml_line_ranges(content); let boundaries = lines .iter() .copied() .filter(|range| is_top_level_yaml_boundary(yaml_line_content(content, *range))) .collect::>(); let mut recovered = serde_norway::Mapping::new(); for (index, (start, _)) in boundaries.iter().copied().enumerate() { let line = yaml_line_content(content, boundaries[index]); if !is_top_level_yaml_mapping_field(line) { continue; } let end = boundaries .get(index + 1) .map(|(next_start, _)| *next_start) .unwrap_or(content.len()); let Ok(value) = serde_norway::from_str::(&content[start..end]) else { continue; }; let Some(mapping) = value.as_mapping() else { continue; }; for (key, value) in mapping { recovered.insert(key.clone(), value.clone()); } } serde_norway::Value::Mapping(recovered) } pub(crate) fn is_top_level_yaml_boundary(line: &str) -> bool { if line.is_empty() || line.chars().next().is_some_and(char::is_whitespace) || line.starts_with('#') || is_indentationless_yaml_sequence_item(line) { return false; } !matches!(line.trim(), "---" | "...") && !line.starts_with('%') } pub(crate) fn is_top_level_yaml_mapping_field(line: &str) -> bool { let mut single_quoted = false; let mut double_quoted = false; let mut escaped = false; for (index, character) in line.char_indices() { if escaped { escaped = false; continue; } if double_quoted && character == '\\' { escaped = true; continue; } match character { '\'' if !double_quoted => single_quoted = !single_quoted, '"' if !single_quoted => double_quoted = !double_quoted, ':' if !single_quoted && !double_quoted => { let rest = &line[index + character.len_utf8()..]; return rest.is_empty() || rest.chars().next().is_some_and(char::is_whitespace); } _ => {} } } false } pub(crate) fn patch_core_network_yaml( content: &str, config: &GuiConfigFile, ) -> Result, String> { patch_core_yaml_document(content, |document| { let original = document.clone(); apply_network_settings(document, config)?; Ok(*document != original) }) } pub(crate) fn patch_core_network_routing_yaml( content: &str, config: &GuiConfigFile, ) -> Result, String> { patch_core_yaml_document(content, |document| { let original = document.clone(); apply_network_settings(document, config)?; set_core_yaml_top_level_value( document, "proxy-url", serde_norway::Value::String(config.proxy_url.clone()), )?; set_core_yaml_nested_value( document, "routing", "session-affinity", serde_norway::Value::Bool(config.routing_session_affinity), )?; set_core_yaml_nested_value( document, "routing", "session-affinity-ttl", serde_norway::Value::String(config.routing_session_affinity_ttl.clone()), )?; set_core_yaml_top_level_value( document, "disable-cooling", serde_norway::Value::Bool(config.disable_cooling), )?; set_core_yaml_top_level_value( document, "request-retry", serde_norway::to_value(config.request_retry) .map_err(|err| format!("序列化请求重试次数失败: {err}"))?, )?; set_core_yaml_top_level_value( document, "max-retry-credentials", serde_norway::to_value(config.max_retry_credentials) .map_err(|err| format!("序列化最大重试凭据数失败: {err}"))?, )?; set_core_yaml_top_level_value( document, "max-retry-interval", serde_norway::to_value(config.max_retry_interval) .map_err(|err| format!("序列化最大重试等待时间失败: {err}"))?, )?; set_core_yaml_nested_value( document, "streaming", "bootstrap-retries", serde_norway::to_value(config.streaming_bootstrap_retries) .map_err(|err| format!("序列化流式启动重试次数失败: {err}"))?, )?; Ok(*document != original) }) } pub(crate) fn patch_core_network_endpoint_yaml( content: &str, config: &GuiConfigFile, ) -> Result, String> { patch_core_yaml_document(content, |document| { let original = document.clone(); apply_network_settings(document, config)?; set_core_yaml_top_level_value( document, "proxy-url", serde_norway::Value::String(config.proxy_url.clone()), )?; Ok(*document != original) }) } pub(crate) fn patch_core_retry_yaml( content: &str, config: &GuiConfigFile, ) -> Result, String> { patch_core_yaml_document(content, |document| { let original = document.clone(); set_core_yaml_top_level_value( document, "disable-cooling", serde_norway::Value::Bool(config.disable_cooling), )?; set_core_yaml_top_level_value( document, "request-retry", serde_norway::to_value(config.request_retry).map_err(|err| err.to_string())?, )?; set_core_yaml_top_level_value( document, "max-retry-credentials", serde_norway::to_value(config.max_retry_credentials).map_err(|err| err.to_string())?, )?; set_core_yaml_top_level_value( document, "max-retry-interval", serde_norway::to_value(config.max_retry_interval).map_err(|err| err.to_string())?, )?; set_core_yaml_nested_value( document, "streaming", "bootstrap-retries", serde_norway::to_value(config.streaming_bootstrap_retries) .map_err(|err| err.to_string())?, )?; Ok(*document != original) }) } pub(crate) fn patch_core_session_routing_yaml( content: &str, config: &GuiConfigFile, ) -> Result, String> { patch_core_yaml_document(content, |document| { let original = document.clone(); set_core_yaml_nested_value( document, "routing", "session-affinity", serde_norway::Value::Bool(config.routing_session_affinity), )?; set_core_yaml_nested_value( document, "routing", "session-affinity-ttl", serde_norway::Value::String(config.routing_session_affinity_ttl.clone()), )?; Ok(*document != original) }) } pub(crate) fn merge_yaml_values(base: &mut serde_norway::Value, current: serde_norway::Value) { match (base, current) { ( serde_norway::Value::Mapping(base_mapping), serde_norway::Value::Mapping(current_mapping), ) => { for (key, current_value) in current_mapping { if let Some(base_value) = base_mapping.get_mut(&key) { merge_yaml_values(base_value, current_value); } else { base_mapping.insert(key, current_value); } } } (base, current) => *base = current, } } pub(crate) fn apply_network_settings( document: &mut serde_norway::Value, config: &GuiConfigFile, ) -> Result<(), String> { let mapping = document .as_mapping_mut() .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; let host = config.host.trim(); mapping.insert( serde_norway::Value::String("host".to_string()), serde_norway::Value::String(host.to_string()), ); mapping.insert( serde_norway::Value::String("port".to_string()), serde_norway::to_value(config.port).map_err(|err| format!("序列化内核端口失败: {err}"))?, ); Ok(()) } pub(crate) fn apply_gui_managed_settings( content: &str, config: &GuiConfigFile, ) -> Result { let host = config.host.trim(); let updated = patch_core_yaml_document(content, |document| { let mut changed = false; changed |= set_core_yaml_top_level_value( document, "host", serde_norway::Value::String(host.to_string()), )?; changed |= set_core_yaml_top_level_value( document, "port", serde_norway::to_value(config.port) .map_err(|err| format!("序列化内核端口失败: {err}"))?, )?; changed |= set_core_yaml_top_level_value( document, "auth-dir", serde_norway::Value::String(config.auth_dir.clone()), )?; changed |= set_core_yaml_top_level_value( document, "debug", serde_norway::Value::Bool(config.debug), )?; changed |= set_core_yaml_top_level_value( document, "commercial-mode", serde_norway::Value::Bool(config.commercial_mode), )?; changed |= set_core_yaml_top_level_value( document, "logging-to-file", serde_norway::Value::Bool(config.logging_to_file), )?; changed |= set_core_yaml_top_level_value( document, "logs-max-total-size-mb", serde_norway::to_value(config.logs_max_total_size_mb) .map_err(|err| format!("序列化日志容量限制失败: {err}"))?, )?; changed |= set_core_yaml_top_level_value( document, "error-logs-max-files", serde_norway::to_value(config.error_logs_max_files) .map_err(|err| format!("序列化错误日志保留数失败: {err}"))?, )?; changed |= set_core_yaml_top_level_value( document, "usage-statistics-enabled", serde_norway::Value::Bool(config.usage_statistics_enabled), )?; changed |= set_core_yaml_top_level_value( document, "redis-usage-queue-retention-seconds", serde_norway::to_value(config.redis_usage_queue_retention_seconds) .map_err(|err| format!("序列化 Redis 用量队列保留时间失败: {err}"))?, )?; changed |= set_core_yaml_top_level_value( document, "request-log", serde_norway::Value::Bool(config.request_log), )?; changed |= set_core_yaml_nested_value( document, "remote-management", "secret-key", serde_norway::Value::String(config.management_secret_key.clone()), )?; changed |= set_core_yaml_nested_value( document, "plugins", "enabled", serde_norway::Value::Bool(config.plugins_enabled), )?; changed |= set_core_yaml_nested_value( document, "routing", "strategy", serde_norway::Value::String(config.routing_strategy.clone()), )?; changed |= set_core_yaml_top_level_value( document, "proxy-url", serde_norway::Value::String(config.proxy_url.clone()), )?; changed |= set_core_yaml_nested_value( document, "routing", "session-affinity", serde_norway::Value::Bool(config.routing_session_affinity), )?; changed |= set_core_yaml_nested_value( document, "routing", "session-affinity-ttl", serde_norway::Value::String(config.routing_session_affinity_ttl.clone()), )?; changed |= set_core_yaml_top_level_value( document, "disable-cooling", serde_norway::Value::Bool(config.disable_cooling), )?; changed |= set_core_yaml_top_level_value( document, "request-retry", serde_norway::to_value(config.request_retry) .map_err(|err| format!("序列化请求重试次数失败: {err}"))?, )?; changed |= set_core_yaml_top_level_value( document, "max-retry-credentials", serde_norway::to_value(config.max_retry_credentials) .map_err(|err| format!("序列化最大重试凭据数失败: {err}"))?, )?; changed |= set_core_yaml_top_level_value( document, "max-retry-interval", serde_norway::to_value(config.max_retry_interval) .map_err(|err| format!("序列化最大重试等待时间失败: {err}"))?, )?; changed |= set_core_yaml_nested_value( document, "streaming", "bootstrap-retries", serde_norway::to_value(config.streaming_bootstrap_retries) .map_err(|err| format!("序列化流式启动重试次数失败: {err}"))?, )?; Ok(changed) })? .unwrap_or_else(|| content.to_string()); let updated = patch_core_api_keys_yaml(&updated, &gui_api_key_values(&config.api_keys))?; serde_norway::from_str::(&updated) .map_err(|err| format!("验证启动内核配置失败: {err}"))?; Ok(updated) } pub(crate) fn write_bytes_directly(path: &Path, content: &[u8]) -> Result<(), String> { let directory = path.parent().unwrap_or_else(|| Path::new(".")); fs::create_dir_all(directory) .map_err(|error| format!("创建配置目录失败 {}: {error}", path_to_string(directory)))?; let write_result = (|| -> io::Result<()> { let mut file = fs::OpenOptions::new() .create(true) .write(true) .truncate(false) .open(path)?; file.seek(SeekFrom::Start(0))?; file.write_all(content)?; file.set_len(content.len() as u64)?; file.sync_all() })(); write_result.map_err(|error| format!("直接写入配置失败 {}: {error}", path_to_string(path)))?; remember_software_write(path, content); Ok(()) } pub(crate) fn write_bytes_atomically(path: &Path, content: &[u8]) -> Result<(), String> { use std::sync::atomic::{AtomicU64, Ordering}; static SEQUENCE: AtomicU64 = AtomicU64::new(0); let directory = path.parent().unwrap_or_else(|| Path::new(".")); fs::create_dir_all(directory) .map_err(|error| format!("创建配置目录失败 {}: {error}", path_to_string(directory)))?; let file_name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("config.yaml"); let temporary_path = directory.join(format!( ".{file_name}.tmp.{}.{}", std::process::id(), SEQUENCE.fetch_add(1, Ordering::Relaxed) )); let write_result = (|| -> io::Result<()> { let mut file = File::create(&temporary_path)?; file.write_all(content)?; file.sync_all()?; // ReplaceFileW requires the replacement file handle to be closed. // Unix rename permits replacing an open file, so this otherwise only // surfaces on Windows as ERROR_SHARING_VIOLATION (os error 32). drop(file); replace_file_atomically(&temporary_path, path) })(); if let Err(error) = write_result { let _ = fs::remove_file(&temporary_path); return Err(format!( "原子写入配置失败 {}: {error}", path_to_string(path) )); } remember_software_write(path, content); Ok(()) } pub(crate) fn normalized_config_path(path: &Path) -> PathBuf { fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } pub(crate) fn remember_software_write(path: &Path, content: &[u8]) { if let Ok(mut hashes) = CONFIG_WRITE_HASHES.lock() { hashes.insert(normalized_config_path(path), sha256_bytes(content)); } } pub(crate) fn consume_software_write(path: &Path) -> bool { let Ok(content) = fs::read(path) else { return false; }; let key = normalized_config_path(path); let hash = sha256_bytes(&content); let Ok(mut hashes) = CONFIG_WRITE_HASHES.lock() else { return false; }; hashes .remove(&key) .is_some_and(|expected_hash| expected_hash == hash) } pub(crate) fn write_yaml_if_changed(path: &Path, content: &str) -> Result { if fs::read_to_string(path).ok().as_deref() == Some(content) { return Ok(false); } write_bytes_atomically(path, content.as_bytes())?; Ok(true) } #[cfg(not(windows))] pub(crate) fn replace_file_atomically( temporary_path: &Path, destination_path: &Path, ) -> io::Result<()> { fs::rename(temporary_path, destination_path) } #[cfg(windows)] pub(crate) fn retry_windows_file_replace( mut replace: impl FnMut() -> io::Result<()>, mut wait: impl FnMut(Duration), ) -> io::Result<()> { use windows_sys::Win32::Foundation::{ ERROR_ACCESS_DENIED, ERROR_LOCK_VIOLATION, ERROR_SHARING_VIOLATION, ERROR_UNABLE_TO_REMOVE_REPLACED, }; let mut delays = [50, 100, 200, 400, 800].into_iter(); loop { match replace() { Ok(()) => return Ok(()), Err(error) => { let retryable = matches!( error.raw_os_error().map(|code| code as u32), Some( ERROR_ACCESS_DENIED | ERROR_LOCK_VIOLATION | ERROR_SHARING_VIOLATION | ERROR_UNABLE_TO_REMOVE_REPLACED ) ); if retryable { if let Some(delay) = delays.next() { wait(Duration::from_millis(delay)); continue; } } return Err(error); } } } } #[cfg(windows)] pub(crate) fn replace_file_atomically( temporary_path: &Path, destination_path: &Path, ) -> io::Result<()> { use std::{os::windows::ffi::OsStrExt, ptr}; use windows_sys::Win32::Storage::FileSystem::ReplaceFileW; let destination = destination_path .as_os_str() .encode_wide() .chain(Some(0)) .collect::>(); let replacement = temporary_path .as_os_str() .encode_wide() .chain(Some(0)) .collect::>(); retry_windows_file_replace( || { if !destination_path.exists() { return fs::rename(temporary_path, destination_path); } let replaced = unsafe { ReplaceFileW( destination.as_ptr(), replacement.as_ptr(), ptr::null(), 0, ptr::null(), ptr::null(), ) }; if replaced == 0 { return Err(io::Error::last_os_error()); } Ok(()) }, thread::sleep, ) } pub(crate) fn fixed_oauth_dir() -> Result { Ok(core_base_dir()?.join(OAUTH_DIR_NAME)) } pub(crate) fn auth_dir_path_for_core(auth_dir: &str, install_dir: &Path) -> PathBuf { if auth_dir.trim() == DEFAULT_AUTH_DIR { return install_dir .parent() .map(|parent| parent.join(OAUTH_DIR_NAME)) .unwrap_or_else(|| install_dir.join(auth_dir)); } let auth_dir = PathBuf::from(auth_dir); if auth_dir.is_absolute() { auth_dir } else { install_dir.join(auth_dir) } } pub(crate) fn core_logs_dir_path(auth_dir: &str, install_dir: &Path) -> PathBuf { auth_dir_path_for_core(auth_dir, install_dir).join("logs") } #[cfg(any(target_os = "macos", test))] fn normalize_path_lexically(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::CurDir => {} Component::ParentDir => { if matches!( normalized.components().next_back(), Some(Component::Normal(_)) ) { normalized.pop(); } else if !normalized.has_root() { normalized.push(component.as_os_str()); } } _ => normalized.push(component.as_os_str()), } } normalized } #[cfg(any(target_os = "macos", test))] pub(crate) fn auth_dir_is_inside_macos_app_bundle(path: &Path) -> bool { let normalized = normalize_path_lexically(path); let mut previous_component_was_app = false; for component in normalized.components() { let Component::Normal(name) = component else { previous_component_was_app = false; continue; }; if previous_component_was_app && name == std::ffi::OsStr::new("Contents") { return true; } previous_component_was_app = Path::new(name) .extension() .is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("app")); } false } #[cfg(any(target_os = "macos", test))] fn copy_auth_file_atomically(source: &Path, destination: &Path) -> Result<(), String> { match fs::symlink_metadata(destination) { Ok(metadata) => { if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(format!( "OAuth 迁移目标不是普通文件: {}", path_to_string(destination) )); } let source_bytes = fs::read(source).map_err(|error| { format!("读取旧 OAuth 文件失败 {}: {error}", path_to_string(source)) })?; let destination_bytes = fs::read(destination).map_err(|error| { format!( "读取现有 OAuth 文件失败 {}: {error}", path_to_string(destination) ) })?; if source_bytes == destination_bytes { return Ok(()); } return Err(format!( "OAuth 迁移目标已存在不同内容,未覆盖: {}", path_to_string(destination) )); } Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => { return Err(format!( "检查 OAuth 迁移目标失败 {}: {error}", path_to_string(destination) )); } } use std::sync::atomic::{AtomicU64, Ordering}; static SEQUENCE: AtomicU64 = AtomicU64::new(0); let directory = destination.parent().unwrap_or_else(|| Path::new(".")); fs::create_dir_all(directory).map_err(|error| { format!( "创建 OAuth 迁移目录失败 {}: {error}", path_to_string(directory) ) })?; let file_name = destination .file_name() .map(|name| name.to_string_lossy()) .unwrap_or_else(|| "oauth".into()); let temporary_path = directory.join(format!( ".{file_name}.migration.{}.{}", std::process::id(), SEQUENCE.fetch_add(1, Ordering::Relaxed) )); let copy_result = (|| -> io::Result<()> { fs::copy(source, &temporary_path)?; let temporary_file = fs::OpenOptions::new().write(true).open(&temporary_path)?; temporary_file.sync_all()?; drop(temporary_file); fs::rename(&temporary_path, destination) })(); if let Err(error) = copy_result { let _ = fs::remove_file(&temporary_path); return Err(format!( "复制 OAuth 文件失败 {} -> {}: {error}", path_to_string(source), path_to_string(destination) )); } Ok(()) } #[cfg(any(target_os = "macos", test))] fn copy_auth_directory(source: &Path, destination: &Path) -> Result<(), String> { let source_metadata = match fs::symlink_metadata(source) { Ok(metadata) => metadata, Err(error) if error.kind() == io::ErrorKind::NotFound => { return fs::create_dir_all(destination).map_err(|create_error| { format!( "创建 OAuth 目录失败 {}: {create_error}", path_to_string(destination) ) }); } Err(error) => { return Err(format!( "检查旧 OAuth 目录失败 {}: {error}", path_to_string(source) )); } }; if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { return Err(format!( "旧 OAuth 路径不是普通目录: {}", path_to_string(source) )); } match fs::symlink_metadata(destination) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { return Err(format!( "OAuth 迁移目标不是普通目录: {}", path_to_string(destination) )); } Ok(_) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => { fs::create_dir_all(destination).map_err(|create_error| { format!( "创建 OAuth 迁移目录失败 {}: {create_error}", path_to_string(destination) ) })?; } Err(error) => { return Err(format!( "检查 OAuth 迁移目录失败 {}: {error}", path_to_string(destination) )); } } for entry in fs::read_dir(source) .map_err(|error| format!("读取旧 OAuth 目录失败 {}: {error}", path_to_string(source)))? { let entry = entry.map_err(|error| { format!( "读取旧 OAuth 目录项失败 {}: {error}", path_to_string(source) ) })?; let file_type = entry.file_type().map_err(|error| { format!( "检查旧 OAuth 目录项失败 {}: {error}", path_to_string(&entry.path()) ) })?; if file_type.is_symlink() { return Err(format!( "OAuth 迁移不接受符号链接: {}", path_to_string(&entry.path()) )); } let target = destination.join(entry.file_name()); if file_type.is_dir() { copy_auth_directory(&entry.path(), &target)?; } else if file_type.is_file() { copy_auth_file_atomically(&entry.path(), &target)?; } else { return Err(format!( "OAuth 迁移不支持该文件类型: {}", path_to_string(&entry.path()) )); } } Ok(()) } #[cfg(any(target_os = "macos", test))] pub(crate) fn migrate_auth_dir_from_macos_app_bundle( config: &mut GuiConfigFile, install_dir: &Path, persistent_auth_dir: &Path, ) -> Result { let source = normalize_path_lexically(&auth_dir_path_for_core(&config.auth_dir, install_dir)); if !auth_dir_is_inside_macos_app_bundle(&source) { return Ok(false); } let destination = normalize_path_lexically(persistent_auth_dir); if auth_dir_is_inside_macos_app_bundle(&destination) { return Err(format!( "OAuth 持久化目录不能位于 macOS 应用包内: {}", path_to_string(&destination) )); } copy_auth_directory(&source, &destination)?; config.auth_dir = DEFAULT_AUTH_DIR.to_string(); Ok(true) } #[cfg(target_os = "macos")] fn migrate_packaged_macos_auth_dir(config: &mut GuiConfigFile) -> Result { let previous_auth_dir = config.auth_dir.clone(); let install_dir = core_install_dir()?; let persistent_auth_dir = fixed_oauth_dir()?; let migrated = migrate_auth_dir_from_macos_app_bundle(config, &install_dir, &persistent_auth_dir)?; if migrated { if let Err(error) = patch_core_auth_dir(&config.auth_dir) { config.auth_dir = previous_auth_dir; return Err(format!("更新内核 OAuth 目录失败: {error}")); } } Ok(migrated) } pub(crate) fn should_import_core_api_keys( had_existing_gui_config: bool, core_api_keys: &[String], ) -> bool { had_existing_gui_config || !core_api_keys.is_empty() } pub(crate) fn file_modified_time(path: &Path) -> Option { fs::metadata(path).ok()?.modified().ok() } pub(crate) fn load_or_create_gui_config() -> Result { let config_path = gui_config_path()?; let legacy_config_path = legacy_gui_config_path()?; let gui_config_exists = config_path.is_file(); let legacy_config_exists = legacy_config_path.is_file(); let had_existing_gui_config = gui_config_exists || legacy_config_exists; let (mut config, presence, mut changed, gui_parse_failed) = if gui_config_exists { let content = fs::read_to_string(&config_path) .map_err(|err| format!("读取 GUI 配置失败 {}: {err}", path_to_string(&config_path)))?; match ( toml::from_str::(&content), toml::from_str::(&content), ) { (Ok(config), Ok(presence)) => (config, presence, false, false), _ => ( GuiConfigFile::default(), GuiConfigPresence::default(), true, true, ), } } else if legacy_config_exists { let content = fs::read_to_string(&legacy_config_path).map_err(|err| { format!( "读取旧 GUI 配置失败 {}: {err}", path_to_string(&legacy_config_path) ) })?; let config = serde_yaml::from_str::(&content) .map_err(|err| format!("解析旧 GUI 配置失败: {err}"))?; let presence = serde_yaml::from_str::(&content) .map_err(|err| format!("解析旧 GUI 配置字段失败: {err}"))?; (config, presence, true, false) } else { ( GuiConfigFile::default(), GuiConfigPresence::default(), true, false, ) }; if gui_config_exists { if let Ok(content) = fs::read_to_string(&config_path) { changed |= [ "codex-session-repair-on-launch", "claude-code-working-directory", "claude-code-working-directory-prompt-disabled", ] .iter() .any(|key| { content .lines() .any(|line| line.trim_start().starts_with(key)) }); } } let core_config_path = core_install_dir()?.join(CORE_CONFIG_FILE); let core_is_newer = file_modified_time(&core_config_path) .zip(file_modified_time(&config_path)) .is_some_and(|(core, gui)| core > gui); if core_is_newer && !gui_parse_failed { if let Ok(core_settings) = read_installed_core_config_settings() { apply_core_settings_to_gui_config(&mut config, &core_settings); changed = true; } } let missing_core_settings = presence.host.is_none() || presence.port.is_none() || presence.auth_dir.is_none() || presence.api_keys.is_none() || presence.management_secret_key.is_none() || presence.debug.is_none() || presence.commercial_mode.is_none() || presence.logging_to_file.is_none() || presence.logs_max_total_size_mb.is_none() || presence.error_logs_max_files.is_none() || presence.usage_statistics_enabled.is_none() || presence.redis_usage_queue_retention_seconds.is_none() || presence.request_log.is_none() || presence.plugins_enabled.is_none() || presence.routing_strategy.is_none() || presence.proxy_url.is_none() || presence.routing_session_affinity.is_none() || presence.routing_session_affinity_ttl.is_none() || presence.disable_cooling.is_none() || presence.request_retry.is_none() || presence.max_retry_credentials.is_none() || presence.max_retry_interval.is_none() || presence.streaming_bootstrap_retries.is_none(); if missing_core_settings && !gui_parse_failed { if let Ok(core_settings) = read_installed_core_config_settings() { if presence.host.is_none() { config.host = core_settings.host.clone(); config.allow_lan = !is_loopback_host(&config.host); } if presence.port.is_none() { config.port = core_settings.port; } if presence.auth_dir.is_none() { config.auth_dir = core_settings.auth_dir.clone(); } if presence.api_keys.is_none() && should_import_core_api_keys(had_existing_gui_config, &core_settings.api_keys) { config.api_keys = merge_core_api_keys_with_gui_metadata( &config.api_keys, &core_settings.api_keys, None, ); } if presence.plugins_enabled.is_none() { config.plugins_enabled = core_settings.plugins_enabled; } if presence.usage_statistics_enabled.is_none() { config.usage_statistics_enabled = core_settings.usage_statistics_enabled; } if presence.debug.is_none() { config.debug = core_settings.debug; } if presence.commercial_mode.is_none() { config.commercial_mode = core_settings.commercial_mode; } if presence.logging_to_file.is_none() { config.logging_to_file = core_settings.logging_to_file; } if presence.logs_max_total_size_mb.is_none() { config.logs_max_total_size_mb = core_settings.logs_max_total_size_mb; } if presence.error_logs_max_files.is_none() { config.error_logs_max_files = core_settings.error_logs_max_files; } if presence.redis_usage_queue_retention_seconds.is_none() { config.redis_usage_queue_retention_seconds = core_settings.redis_usage_queue_retention_seconds; } if presence.request_log.is_none() { config.request_log = core_settings.request_log; } if presence.management_secret_key.is_none() { config.management_secret_key = core_settings .management_secret_key .as_deref() .filter(|secret_key| !is_hashed_management_secret_key(secret_key)) .unwrap_or_default() .to_string(); } if presence.routing_strategy.is_none() { config.routing_strategy = core_settings.routing_strategy; } if presence.proxy_url.is_none() { config.proxy_url = core_settings.proxy_url; } if presence.routing_session_affinity.is_none() { config.routing_session_affinity = core_settings.routing_session_affinity; } if presence.routing_session_affinity_ttl.is_none() { config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl; } if presence.disable_cooling.is_none() { config.disable_cooling = core_settings.disable_cooling; } if presence.request_retry.is_none() { config.request_retry = core_settings.request_retry; } if presence.max_retry_credentials.is_none() { config.max_retry_credentials = core_settings.max_retry_credentials; } if presence.max_retry_interval.is_none() { config.max_retry_interval = core_settings.max_retry_interval; } if presence.streaming_bootstrap_retries.is_none() { config.streaming_bootstrap_retries = core_settings.streaming_bootstrap_retries; } } changed = true; } if presence.management_secret_key.is_none() && (had_existing_gui_config || core_config_path.is_file()) { config.management_secret_key = read_installed_core_config_settings() .ok() .and_then(|settings| settings.management_secret_key) .filter(|secret_key| !is_hashed_management_secret_key(secret_key)) .unwrap_or_default(); changed = true; } if presence.host.is_none() || presence.auth_dir.is_none() { changed = true; } if presence.locale.is_none() { changed = true; } if presence.close_behavior.is_none() { changed = true; } if presence.start_core_on_launch.is_none() { changed = true; } if presence.silent_start.is_none() { changed = true; } if presence.default_terminal.is_none() { changed = true; } if presence.download_source.is_none() { config.download_source = if config.prefer_gitcode_downloads { VersionDownloadSource::Gitcode } else { VersionDownloadSource::Github }; changed = true; } if presence.custom_download_mirrors.is_none() || presence.active_custom_download_mirror.is_none() { changed = true; } if presence.prefer_gitcode_downloads.is_none() { changed = true; } config.prefer_gitcode_downloads = config.download_source == VersionDownloadSource::Gitcode; let management_secret_rotated = ensure_strong_management_secret(&mut config)?; changed |= management_secret_rotated; changed |= sanitize_gui_config(&mut config)?; validate_gui_config(&config)?; if changed { write_gui_config(&config)?; } if management_secret_rotated { if let Err(error) = patch_core_management_secret_key(&config.management_secret_key) { eprintln!("更新旧版 CPA WebUI 密钥失败,将在下次启动内核时重试: {error}"); } } if !config.auth_dir.trim().is_empty() { let install_dir = core_install_dir()?; let auth_dir = auth_dir_path_for_core(&config.auth_dir, &install_dir); fs::create_dir_all(&auth_dir) .map_err(|error| format!("创建凭证目录失败 {}: {error}", path_to_string(&auth_dir)))?; } Ok(config) } pub(crate) fn apply_core_settings_to_gui_config( config: &mut GuiConfigFile, core_settings: &CoreConfigSettings, ) { config.host = core_settings.host.clone(); config.port = core_settings.port; config.allow_lan = !is_loopback_host(&core_settings.host); config.auth_dir = core_settings.auth_dir.clone(); config.api_keys = merge_core_api_keys_with_gui_metadata(&config.api_keys, &core_settings.api_keys, None); if let Some(secret_key) = core_settings .management_secret_key .as_deref() .filter(|secret_key| !is_hashed_management_secret_key(secret_key)) { config.management_secret_key = secret_key.to_string(); } config.debug = core_settings.debug; config.commercial_mode = core_settings.commercial_mode; config.logging_to_file = core_settings.logging_to_file; config.logs_max_total_size_mb = core_settings.logs_max_total_size_mb; config.error_logs_max_files = core_settings.error_logs_max_files; config.usage_statistics_enabled = core_settings.usage_statistics_enabled; config.redis_usage_queue_retention_seconds = core_settings.redis_usage_queue_retention_seconds; config.request_log = core_settings.request_log; config.plugins_enabled = core_settings.plugins_enabled; config.routing_strategy = core_settings.routing_strategy.clone(); config.proxy_url = core_settings.proxy_url.clone(); config.routing_session_affinity = core_settings.routing_session_affinity; config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl.clone(); config.disable_cooling = core_settings.disable_cooling; config.request_retry = core_settings.request_retry; config.max_retry_credentials = core_settings.max_retry_credentials; config.max_retry_interval = core_settings.max_retry_interval; config.streaming_bootstrap_retries = core_settings.streaming_bootstrap_retries; } pub(crate) fn default_api_key_entry() -> GuiApiKeyEntry { GuiApiKeyEntry { key: DEFAULT_API_KEY.to_string(), remark: DEFAULT_API_KEY_INITIAL_REMARK.to_string(), } } pub(crate) fn gui_api_key_values(entries: &[GuiApiKeyEntry]) -> Vec { entries.iter().map(|entry| entry.key.clone()).collect() } pub(crate) fn effective_agent_api_key(config: &GuiConfigFile) -> &str { config .api_keys .iter() .map(|entry| entry.key.trim()) .find(|key| !key.is_empty()) .unwrap_or(DEFAULT_API_KEY) } pub(crate) fn merge_core_api_keys_with_gui_metadata( existing: &[GuiApiKeyEntry], core_api_keys: &[String], added_api_key: Option<&GuiApiKeyEntry>, ) -> Vec { let mut merged: Vec = Vec::new(); for api_key in core_api_keys { let api_key = api_key.trim(); if api_key.is_empty() || is_example_core_api_key(api_key) { continue; } if merged.iter().any(|entry| entry.key == api_key) { continue; } let remark = added_api_key .filter(|entry| entry.key == api_key) .map(|entry| entry.remark.clone()) .or_else(|| { existing .iter() .find(|entry| entry.key == api_key) .map(|entry| entry.remark.clone()) }) .unwrap_or_default(); merged.push(GuiApiKeyEntry { key: api_key.to_string(), remark, }); } merged } pub(crate) fn normalized_saved_window_size(width: u32, height: u32) -> SavedWindowSize { SavedWindowSize { width: width.clamp(MIN_MAIN_WINDOW_WIDTH, MAX_SAVED_WINDOW_DIMENSION), height: height.clamp(MIN_MAIN_WINDOW_HEIGHT, MAX_SAVED_WINDOW_DIMENSION), } } pub(crate) fn configured_window_size(config: &GuiConfigFile) -> Option { match (config.window_width, config.window_height) { (Some(width), Some(height)) => Some(normalized_saved_window_size(width, height)), _ => None, } } pub(crate) fn logical_window_size_from_physical( physical_size: &tauri::PhysicalSize, scale_factor: f64, ) -> Option { if !scale_factor.is_finite() || scale_factor <= 0.0 { return None; } let width = (f64::from(physical_size.width) / scale_factor).round() as u32; let height = (f64::from(physical_size.height) / scale_factor).round() as u32; if width < MIN_MAIN_WINDOW_WIDTH || height < MIN_MAIN_WINDOW_HEIGHT { return None; } Some(normalized_saved_window_size(width, height)) } pub(crate) fn fit_window_size_to_current_monitor( window: &tauri::WebviewWindow, saved_size: SavedWindowSize, ) -> SavedWindowSize { let monitor = window .current_monitor() .ok() .flatten() .or_else(|| window.primary_monitor().ok().flatten()); let Some(monitor) = monitor else { return saved_size; }; let scale_factor = monitor.scale_factor(); if !scale_factor.is_finite() || scale_factor <= 0.0 { return saved_size; } let (frame_width, frame_height) = window .outer_size() .ok() .zip(window.inner_size().ok()) .map(|(outer, inner)| { ( outer.width.saturating_sub(inner.width), outer.height.saturating_sub(inner.height), ) }) .unwrap_or_default(); let work_area = monitor.work_area(); let max_width = (f64::from(work_area.size.width.saturating_sub(frame_width)) / scale_factor).floor() as u32; let max_height = (f64::from(work_area.size.height.saturating_sub(frame_height)) / scale_factor) .floor() as u32; SavedWindowSize { width: saved_size.width.min(max_width.max(MIN_MAIN_WINDOW_WIDTH)), height: saved_size .height .min(max_height.max(MIN_MAIN_WINDOW_HEIGHT)), } } pub(crate) fn restore_main_window_size(app: &tauri::AppHandle) -> Result<(), String> { let window_size_state = app.state::(); let Some(saved_size) = window_size_state.snapshot()? else { return Ok(()); }; let window = app .get_webview_window("main") .ok_or_else(|| "主窗口不存在,无法恢复窗口尺寸".to_string())?; let restored_size = fit_window_size_to_current_monitor(&window, saved_size); window .set_size(LogicalSize::new( f64::from(restored_size.width), f64::from(restored_size.height), )) .map_err(|error| format!("恢复主窗口尺寸失败: {error}"))?; window_size_state.replace(restored_size) } pub(crate) fn persist_main_window_size(app: &tauri::AppHandle) -> Result<(), String> { let window_size_state = app.state::(); let saved_size = match window_size_state.snapshot()? { Some(size) => size, None => { let window = app .get_webview_window("main") .ok_or_else(|| "主窗口不存在,无法保存窗口尺寸".to_string())?; let physical_size = window .inner_size() .map_err(|error| format!("读取主窗口尺寸失败: {error}"))?; let scale_factor = window .scale_factor() .map_err(|error| format!("读取主窗口缩放比例失败: {error}"))?; logical_window_size_from_physical(&physical_size, scale_factor) .ok_or_else(|| "主窗口尺寸无效,已跳过保存".to_string())? } }; app.state::() .set_window_size(saved_size) .map(|_| ()) } pub(crate) fn is_loopback_host(host: &str) -> bool { let host = host.trim(); host.eq_ignore_ascii_case("localhost") || host .trim_start_matches('[') .trim_end_matches(']') .parse::() .is_ok_and(|address| address.is_loopback()) } pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { let mut changed = false; let normalized_locale = normalize_app_locale(&config.locale); if config.locale != normalized_locale { config.locale = normalized_locale.to_string(); changed = true; } let default_terminal = normalize_agent_terminal(&config.default_terminal); if config.default_terminal != default_terminal { config.default_terminal = default_terminal; changed = true; } let host = config.host.trim(); let host = if host.is_empty() { if config.allow_lan { "0.0.0.0" } else { "127.0.0.1" } } else { host }; if config.host != host { config.host = host.to_string(); changed = true; } let allow_lan = !is_loopback_host(&config.host); if config.allow_lan != allow_lan { config.allow_lan = allow_lan; changed = true; } #[cfg(target_os = "macos")] { changed |= migrate_packaged_macos_auth_dir(config)?; } let legacy_default_auth_dir = fixed_oauth_dir()?; if config.auth_dir.trim().is_empty() || Path::new(config.auth_dir.trim()) == legacy_default_auth_dir { config.auth_dir = DEFAULT_AUTH_DIR.to_string(); changed = true; } let original_api_keys = config.api_keys.clone(); let configured_keys = config .api_keys .iter() .map(|entry| entry.key.trim().to_string()) .collect::>(); config.api_keys = merge_core_api_keys_with_gui_metadata(&config.api_keys, &configured_keys, None); for entry in &mut config.api_keys { entry.key = entry.key.trim().to_string(); entry.remark = entry.remark.trim().to_string(); } if config.api_keys != original_api_keys { changed = true; } let proxy_url = config.proxy_url.trim().to_string(); if config.proxy_url != proxy_url { config.proxy_url = proxy_url; changed = true; } let original_custom_mirrors = config.custom_download_mirrors.clone(); config.custom_download_mirrors = config .custom_download_mirrors .iter() .filter_map(|url| normalize_custom_download_mirror_url(url).ok()) .fold(Vec::new(), |mut mirrors, url| { if !mirrors.contains(&url) { mirrors.push(url); } mirrors }); if config.custom_download_mirrors != original_custom_mirrors { changed = true; } if !config.active_custom_download_mirror.is_empty() { match normalize_custom_download_mirror_url(&config.active_custom_download_mirror) { Ok(normalized) if normalized != config.active_custom_download_mirror => { config.active_custom_download_mirror = normalized; changed = true; } Err(_) => { config.active_custom_download_mirror.clear(); changed = true; } _ => {} } } if config.download_source == VersionDownloadSource::Custom && !config .custom_download_mirrors .contains(&config.active_custom_download_mirror) { config.download_source = VersionDownloadSource::Github; config.active_custom_download_mirror.clear(); changed = true; } let routing_session_affinity_ttl = config.routing_session_affinity_ttl.trim().to_string(); if config.routing_session_affinity_ttl != routing_session_affinity_ttl { config.routing_session_affinity_ttl = routing_session_affinity_ttl; changed = true; } let window_size = configured_window_size(config).map(|size| { if size.width == LEGACY_DEFAULT_MAIN_WINDOW_WIDTH && size.height == LEGACY_DEFAULT_MAIN_WINDOW_HEIGHT { SavedWindowSize { width: DEFAULT_MAIN_WINDOW_WIDTH, height: DEFAULT_MAIN_WINDOW_HEIGHT, } } else { size } }); let normalized_width = window_size.map(|size| size.width); let normalized_height = window_size.map(|size| size.height); if config.window_width != normalized_width || config.window_height != normalized_height { config.window_width = normalized_width; config.window_height = normalized_height; changed = true; } Ok(changed) } #[allow(dead_code)] pub(crate) fn write_gui_config_legacy(config: &GuiConfigFile) -> Result<(), String> { validate_gui_config(config)?; let config_path = gui_config_path()?; let content = toml::to_string_pretty(config).map_err(|err| format!("序列化 GUI 配置失败: {err}"))?; write_yaml_if_changed(&config_path, &content).map(|_| ()) } pub(crate) fn write_gui_config(config: &GuiConfigFile) -> Result<(), String> { write_gui_config_to_path(config, &gui_config_path()?) } pub(crate) fn write_gui_config_to_path( config: &GuiConfigFile, config_path: &Path, ) -> Result<(), String> { use toml_edit::{value, Array, Document, InlineTable, Item, Value}; validate_gui_config(config)?; let existing = fs::read_to_string(config_path).ok(); let mut document = existing .as_deref() .filter(|content| !content.trim().is_empty()) .and_then(|content| content.parse::().ok()) .unwrap_or_default(); let root = document.as_table_mut(); for (key, item) in [ ("locale", value(config.locale.as_str())), ("port", value(i64::from(config.port))), ("allow-lan", value(config.allow_lan)), ("host", value(config.host.as_str())), ("run-on-startup", value(config.run_on_startup)), ("start-core-on-launch", value(config.start_core_on_launch)), ("silent-start", value(config.silent_start)), ("close-behavior", value(config.close_behavior.as_str())), ("default-terminal", value(config.default_terminal.as_str())), ("auth-dir", value(config.auth_dir.as_str())), ( "management-secret-key", value(config.management_secret_key.as_str()), ), ("debug", value(config.debug)), ("commercial-mode", value(config.commercial_mode)), ("logging-to-file", value(config.logging_to_file)), ( "logs-max-total-size-mb", value(i64::from(config.logs_max_total_size_mb)), ), ( "error-logs-max-files", value(i64::from(config.error_logs_max_files)), ), ( "usage-statistics-enabled", value(config.usage_statistics_enabled), ), ( "redis-usage-queue-retention-seconds", value(i64::from(config.redis_usage_queue_retention_seconds)), ), ("request-log", value(config.request_log)), ("plugins-enabled", value(config.plugins_enabled)), ("routing-strategy", value(config.routing_strategy.as_str())), ("proxy-url", value(config.proxy_url.as_str())), ("download-source", value(config.download_source.as_str())), ( "prefer-gitcode-downloads", value(config.prefer_gitcode_downloads), ), ( "routing-session-affinity", value(config.routing_session_affinity), ), ( "routing-session-affinity-ttl", value(config.routing_session_affinity_ttl.as_str()), ), ("disable-cooling", value(config.disable_cooling)), ("request-retry", value(i64::from(config.request_retry))), ( "max-retry-credentials", value(i64::from(config.max_retry_credentials)), ), ( "max-retry-interval", value(i64::from(config.max_retry_interval)), ), ( "streaming-bootstrap-retries", value(i64::from(config.streaming_bootstrap_retries)), ), ] { set_codex_table_item(root, key, item); } for key in [ "codex-session-repair-on-launch", "claude-code-working-directory", "claude-code-working-directory-prompt-disabled", ] { root.remove(key); } for (key, dimension) in [ ("window-width", config.window_width), ("window-height", config.window_height), ] { if let Some(dimension) = dimension { set_codex_table_item(root, key, value(i64::from(dimension))); } else { root.remove(key); } } let mut api_keys = Array::new(); for entry in &config.api_keys { let mut table = InlineTable::new(); table.insert("key", Value::from(entry.key.as_str())); table.insert("remark", Value::from(entry.remark.as_str())); api_keys.push(Value::InlineTable(table)); } set_codex_table_item(root, "api-keys", Item::Value(Value::Array(api_keys))); let mut custom_download_mirrors = Array::new(); for url in &config.custom_download_mirrors { custom_download_mirrors.push(url.as_str()); } set_codex_table_item( root, "custom-download-mirrors", Item::Value(Value::Array(custom_download_mirrors)), ); set_codex_table_item( root, "active-custom-download-mirror", value(config.active_custom_download_mirror.as_str()), ); let mut api_access_remarks = Array::new(); for entry in &config.api_access_remarks { let mut table = InlineTable::new(); table.insert( "provider-section", Value::from(entry.provider_section.as_str()), ); table.insert("api-key-hash", Value::from(entry.api_key_hash.as_str())); table.insert("remark", Value::from(entry.remark.as_str())); api_access_remarks.push(Value::InlineTable(table)); } set_codex_table_item( root, "api-access-remarks", Item::Value(Value::Array(api_access_remarks)), ); let content = document.to_string(); toml::from_str::(&content) .map_err(|error| format!("验证 GUI 配置失败: {error}"))?; if existing.as_deref() == Some(content.as_str()) { return Ok(()); } write_bytes_directly(config_path, content.as_bytes()) } pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> { if config.port == 0 { return Err("GUI 配置端口必须在 1 到 65535 之间".to_string()); } if config.host.trim().is_empty() || config.host.chars().any(char::is_control) { return Err("GUI 配置 host 无效".to_string()); } if config.auth_dir.trim().is_empty() || config.auth_dir.chars().any(char::is_control) { return Err("凭证目录不能为空或包含控制字符".to_string()); } #[cfg(target_os = "macos")] { let auth_dir = auth_dir_path_for_core(&config.auth_dir, &core_install_dir()?); if auth_dir_is_inside_macos_app_bundle(&auth_dir) { return Err("OAuth 凭证目录不能位于 macOS 应用包内".to_string()); } } for entry in &config.api_keys { validate_core_api_key(&entry.key)?; validate_api_key_remark(&entry.remark)?; } for entry in &config.api_access_remarks { validate_api_access_provider_section(&entry.provider_section)?; if entry.api_key_hash.len() != 64 || !entry .api_key_hash .chars() .all(|character| character.is_ascii_hexdigit()) { return Err("API 接入备注的密钥指纹无效".to_string()); } validate_api_key_remark(&entry.remark)?; } validate_strong_management_secret_key(&config.management_secret_key)?; validate_routing_strategy(config.routing_strategy.trim())?; if config.proxy_url.chars().any(char::is_control) { return Err("代理 URL 不能包含控制字符".to_string()); } for url in &config.custom_download_mirrors { if normalize_custom_download_mirror_url(url).as_deref() != Ok(url.as_str()) { return Err(format!("自定义下载镜像地址无效: {url}")); } } if config.download_source == VersionDownloadSource::Custom && !config .custom_download_mirrors .contains(&config.active_custom_download_mirror) { return Err("当前选择的自定义下载镜像不存在".to_string()); } if config .routing_session_affinity_ttl .chars() .any(char::is_control) { return Err("会话粘性 TTL 不能包含控制字符".to_string()); } if !(1..=3600).contains(&config.redis_usage_queue_retention_seconds) { return Err("Redis 用量队列保留时间必须在 1 到 3600 秒之间".to_string()); } Ok(()) } pub(crate) fn gui_config_path() -> Result { Ok(core_base_dir()?.join(GUI_CONFIG_FILE)) } pub(crate) fn legacy_gui_config_path() -> Result { Ok(core_base_dir()?.join(LEGACY_GUI_CONFIG_FILE)) }