- Retry errors 5, 32, 33, and 1175 up to five times with exponential backoff - Preserve the final error and avoid retrying partial replacement failures - Add eight regression tests covering transient and persistent file locks Closes #241
1983 lines
69 KiB
Rust
1983 lines
69 KiB
Rust
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<String> {
|
|
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<String, String> {
|
|
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<bool, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<CoreTlsSettings, String> {
|
|
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>,
|
|
) -> 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<String, String> {
|
|
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<String, String> {
|
|
let current_value = current
|
|
.map(|current| {
|
|
let current = serde_norway::from_str::<serde_norway::Value>(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<String, String> {
|
|
let current = match serde_norway::from_str::<serde_norway::Value>(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<serde_norway::Value>,
|
|
) -> Result<String, String> {
|
|
let template_value = serde_norway::from_str::<serde_norway::Value>(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::<serde_norway::Value>(&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::<Vec<_>>();
|
|
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::<serde_norway::Value>(&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<Option<String>, 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<Option<String>, 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<Option<String>, 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<Option<String>, 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<Option<String>, 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<String, String> {
|
|
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::<serde_norway::Value>(&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<bool, String> {
|
|
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::<Vec<_>>();
|
|
let replacement = temporary_path
|
|
.as_os_str()
|
|
.encode_wide()
|
|
.chain(Some(0))
|
|
.collect::<Vec<_>>();
|
|
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<PathBuf, String> {
|
|
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<bool, String> {
|
|
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<bool, String> {
|
|
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<SystemTime> {
|
|
fs::metadata(path).ok()?.modified().ok()
|
|
}
|
|
|
|
pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
|
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::<GuiConfigFile>(&content),
|
|
toml::from_str::<GuiConfigPresence>(&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::<GuiConfigFile>(&content)
|
|
.map_err(|err| format!("解析旧 GUI 配置失败: {err}"))?;
|
|
let presence = serde_yaml::from_str::<GuiConfigPresence>(&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<String> {
|
|
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<GuiApiKeyEntry> {
|
|
let mut merged: Vec<GuiApiKeyEntry> = 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<SavedWindowSize> {
|
|
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<u32>,
|
|
scale_factor: f64,
|
|
) -> Option<SavedWindowSize> {
|
|
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<R: tauri::Runtime>(
|
|
window: &tauri::WebviewWindow<R>,
|
|
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::<MainWindowSizeState>();
|
|
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::<MainWindowSizeState>();
|
|
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::<GuiConfigState>()
|
|
.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::<IpAddr>()
|
|
.is_ok_and(|address| address.is_loopback())
|
|
}
|
|
|
|
pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, String> {
|
|
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::<Vec<_>>();
|
|
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::<Document>().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::<GuiConfigFile>(&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<PathBuf, String> {
|
|
Ok(core_base_dir()?.join(GUI_CONFIG_FILE))
|
|
}
|
|
|
|
pub(crate) fn legacy_gui_config_path() -> Result<PathBuf, String> {
|
|
Ok(core_base_dir()?.join(LEGACY_GUI_CONFIG_FILE))
|
|
}
|