Files
EasyCLIProxyAPI/src-tauri/src/main.rs
T

11506 lines
399 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod usage;
use flate2::read::GzDecoder;
use futures_util::StreamExt;
#[cfg(target_os = "macos")]
use objc2::MainThreadMarker;
#[cfg(target_os = "macos")]
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{
env, fs,
fs::File,
io::{self, Read, Write},
net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, UdpSocket},
path::{Component, Path, PathBuf},
process::{Child, Command, Stdio},
sync::Mutex,
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tar::Archive;
#[cfg(target_os = "windows")]
use tauri::menu::PredefinedMenuItem;
#[cfg(target_os = "macos")]
use tauri::tray::{MouseButtonState, TrayIcon};
#[cfg(any(target_os = "macos", target_os = "windows"))]
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, Manager};
use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken;
use zip::ZipArchive;
const RELEASE_PAGE_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/releases/latest";
const RELEASE_ATOM_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/releases.atom";
const RELEASE_DOWNLOAD_PREFIX: &str =
"https://github.com/router-for-me/CLIProxyAPI/releases/download/";
const APP_RELEASE_PAGE_URL: &str =
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest";
const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress";
const CORE_STATUS_EVENT: &str = "core-status-changed";
#[cfg(target_os = "windows")]
const WINDOWS_CLOSE_REQUEST_EVENT: &str = "windows-close-requested";
const CORE_METADATA_FILE: &str = "cpa-gui-meta.json";
const CORE_CONFIG_FILE: &str = "config.yaml";
const CORE_EXAMPLE_CONFIG_FILE: &str = "config.example.yaml";
const CORE_VERSION_FILE: &str = "core-version.txt";
const CORE_CHECKSUMS_FILE: &str = "checksums.txt";
const GUI_CONFIG_FILE: &str = "config.toml";
const LEGACY_GUI_CONFIG_FILE: &str = "cpa-gui.yaml";
const OAUTH_DIR_NAME: &str = "oauth";
const DEFAULT_API_KEY: &str = "123456";
const DEFAULT_API_KEY_REMARK: &str = "内置密钥";
const DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456";
const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui";
const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json";
const DEFAULT_CODEX_CONTEXT_WINDOW: u64 = 128_000;
const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700";
const AGENT_MODIFICATION_STATE_VERSION: u8 = 1;
const AGENT_PHASE_APPLYING: &str = "applying";
const AGENT_PHASE_ACTIVE: &str = "active";
const AGENT_PHASE_RESTORING: &str = "restoring";
const AGENT_PHASE_RECOVERY: &str = "recovery";
const AGENT_MODIFICATION_STATE_CONFLICT: &str = "conflict";
const USER_AGENT: &str = concat!(
"CPA-GUI/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/router-for-me/CLIProxyAPI)"
);
const APP_USER_AGENT: &str = concat!(
"EasyCLIProxyAPI/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/router-for-me/EasyCLIProxyAPI)"
);
static CORE_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(());
static AGENT_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(());
fn normalize_app_locale(locale: &str) -> &'static str {
let normalized = locale.trim().to_ascii_lowercase();
if normalized.starts_with("en") {
"en"
} else if normalized.starts_with("ja") {
"ja"
} else if normalized == "zh-tw"
|| normalized == "zh-hk"
|| normalized == "zh-mo"
|| normalized.starts_with("zh-hant")
{
"zh-TW"
} else {
"zh-CN"
}
}
fn locale_text<'a>(locale: &str, zh_cn: &'a str, en: &'a str) -> &'a str {
match normalize_app_locale(locale) {
"en" => en,
"zh-TW" => match zh_cn {
"打开主界面" => "開啟主介面",
"退出" => "退出",
"内核状态:处理中" => "核心狀態:處理中",
"内核状态:未安装" => "核心狀態:未安裝",
"内核状态:运行中" => "核心狀態:執行中",
"内核状态:已停止" => "核心狀態:已停止",
"处理中..." => "處理中...",
"停止内核" => "停止核心",
"启动内核" => "啟動核心",
"重启内核" => "重新啟動核心",
"EasyCLIProxyAPI · 内核处理中" => "EasyCLIProxyAPI · 核心處理中",
"EasyCLIProxyAPI · 内核未安装" => "EasyCLIProxyAPI · 核心未安裝",
"EasyCLIProxyAPI · 内核运行中" => "EasyCLIProxyAPI · 核心執行中",
"EasyCLIProxyAPI · 内核已停止" => "EasyCLIProxyAPI · 核心已停止",
"EasyCLIProxyAPI · 内核操作失败" => "EasyCLIProxyAPI · 核心操作失敗",
"内核状态:正在检查" => "核心狀態:正在檢查",
_ => zh_cn,
},
"ja" => match zh_cn {
"打开主界面" => "メイン画面を開く",
"退出" => "終了",
"内核状态:处理中" => "コア状態:処理中",
"内核状态:未安装" => "コア状態:未インストール",
"内核状态:运行中" => "コア状態:実行中",
"内核状态:已停止" => "コア状態:停止済み",
"处理中..." => "処理中...",
"停止内核" => "コアを停止",
"启动内核" => "コアを起動",
"重启内核" => "コアを再起動",
"EasyCLIProxyAPI · 内核处理中" => "EasyCLIProxyAPI · コア処理中",
"EasyCLIProxyAPI · 内核未安装" => "EasyCLIProxyAPI · コア未インストール",
"EasyCLIProxyAPI · 内核运行中" => "EasyCLIProxyAPI · コア実行中",
"EasyCLIProxyAPI · 内核已停止" => "EasyCLIProxyAPI · コア停止済み",
"EasyCLIProxyAPI · 内核操作失败" => "EasyCLIProxyAPI · コア操作失敗",
"内核状态:正在检查" => "コア状態:確認中",
_ => zh_cn,
},
_ => zh_cn,
}
}
#[derive(Default)]
struct CoreDownloadState {
inner: Mutex<CoreDownloadInner>,
}
#[derive(Default)]
struct CoreDownloadInner {
running: bool,
token: Option<CancellationToken>,
task: CoreInstallTask,
}
#[derive(Default)]
struct CoreProcessState {
child: Mutex<Option<Child>>,
#[cfg(windows)]
job: Mutex<Option<isize>>,
}
struct GuiConfigState {
inner: Mutex<GuiConfigFile>,
}
#[derive(Default)]
struct AgentConfigStatusCache {
refresh_lock: Mutex<()>,
entry: Mutex<Option<AgentConfigStatusCacheEntry>>,
}
struct AgentConfigStatusCacheEntry {
port: u16,
statuses: Vec<AgentConfigStatus>,
}
impl AgentConfigStatusCache {
fn get(&self, port: u16) -> Result<Option<Vec<AgentConfigStatus>>, String> {
self.entry
.lock()
.map(|entry| {
entry
.as_ref()
.filter(|entry| entry.port == port)
.map(|entry| entry.statuses.clone())
})
.map_err(|_| "智能体配置状态缓存锁已损坏".to_string())
}
fn replace(&self, port: u16, statuses: Vec<AgentConfigStatus>) -> Result<(), String> {
let mut current = self
.entry
.lock()
.map_err(|_| "智能体配置状态缓存锁已损坏".to_string())?;
*current = Some(AgentConfigStatusCacheEntry { port, statuses });
Ok(())
}
fn clear(&self) -> Result<(), String> {
let mut current = self
.entry
.lock()
.map_err(|_| "智能体配置状态缓存锁已损坏".to_string())?;
*current = None;
Ok(())
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CorePlatform {
os: String,
arch: String,
asset_os: String,
asset_arch: String,
archive_kind: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreStatus {
installed: bool,
running: bool,
managed: bool,
process_id: Option<u32>,
current_version: Option<String>,
install_dir: String,
binary_path: Option<String>,
message: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreLatest {
version: String,
asset_name: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AppUpdateInfo {
current_version: String,
latest_version: String,
update_available: bool,
release_url: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct BundledCoreInfo {
version: String,
asset_name: String,
size_bytes: u64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreInstallResult {
version: String,
asset_name: String,
install_dir: String,
binary_path: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreInstallTask {
running: bool,
cancellable: bool,
phase: String,
downloaded: u64,
total: Option<u64>,
percent: Option<f64>,
message: Option<String>,
result: Option<CoreInstallResult>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
struct GuiConfigFile {
locale: String,
port: u16,
allow_lan: bool,
run_on_startup: bool,
auth_dir: String,
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec<GuiApiKeyEntry>,
management_secret_key: String,
plugins_enabled: bool,
routing_strategy: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum WindowsCloseAction {
Exit,
MinimizeToTray,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct GuiApiKeyEntry {
key: String,
#[serde(default)]
remark: String,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum GuiApiKeyInput {
Legacy(String),
Entry(GuiApiKeyEntry),
}
fn deserialize_gui_api_keys<'de, D>(deserializer: D) -> Result<Vec<GuiApiKeyEntry>, D::Error>
where
D: serde::Deserializer<'de>,
{
let entries = Vec::<GuiApiKeyInput>::deserialize(deserializer)?;
Ok(entries
.into_iter()
.map(|entry| match entry {
GuiApiKeyInput::Legacy(key) => GuiApiKeyEntry {
remark: String::new(),
key,
},
GuiApiKeyInput::Entry(entry) => entry,
})
.collect())
}
impl Default for GuiConfigFile {
fn default() -> Self {
Self {
locale: "zh-CN".to_string(),
port: 8317,
allow_lan: false,
run_on_startup: false,
auth_dir: env::current_exe()
.ok()
.and_then(|path| path.parent().map(|parent| parent.join(OAUTH_DIR_NAME)))
.map(|path| path_to_string(&path))
.unwrap_or_else(|| OAUTH_DIR_NAME.to_string()),
api_keys: vec![built_in_api_key_entry()],
// Keep plaintext here for management API auth. Core hashes the
// value written into config.yaml on startup.
management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
plugins_enabled: false,
routing_strategy: "round-robin".to_string(),
}
}
}
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
struct GuiConfigPresence {
locale: Option<String>,
auth_dir: Option<String>,
api_keys: Option<Vec<GuiApiKeyInput>>,
management_secret_key: Option<String>,
plugins_enabled: Option<bool>,
routing_strategy: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct GuiSettings {
port: u16,
allow_lan: bool,
run_on_startup: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfigStatus {
id: String,
name: String,
supported_platform: bool,
installed: bool,
executable_path: Option<String>,
launch_targets: Vec<AgentLaunchTarget>,
version: Option<String>,
config_paths: Vec<String>,
config_exists: bool,
config_valid: bool,
configured: bool,
current_model: Option<String>,
modification_enabled: bool,
modification_state: String,
backup_available: bool,
applied_model: Option<String>,
warnings: Vec<String>,
error: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentLaunchTarget {
id: String,
label: String,
detail: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfigActionResult {
outcome: String,
enabled: bool,
model: Option<String>,
changed_files: Vec<String>,
conflict_files: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentModelOption {
name: String,
alias: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CodexModelDefinition {
id: String,
display_name: Option<String>,
description: Option<String>,
context_window: Option<u64>,
reasoning_levels: Vec<String>,
supports_tools: Option<bool>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CodexModelCatalogSpec {
id: String,
display_name: String,
description: String,
context_window: u64,
reasoning_levels: Vec<String>,
supports_parallel_tool_calls: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ThinkingAliasEntry {
source_model: String,
alias: String,
effort: Option<String>,
provider: String,
kind: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ThinkingAliasSource {
id: String,
model: String,
display_name: Option<String>,
provider: String,
kind: String,
protocol: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum ThinkingAliasSourceLocation {
CodexOauth,
ConfigModel {
section: &'static str,
provider_index: usize,
model_index: usize,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ResolvedThinkingAliasSource {
source: ThinkingAliasSource,
location: ThinkingAliasSourceLocation,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentModificationRecord {
version: u8,
client: String,
phase: String,
model: String,
files: Vec<AgentModificationFile>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentModificationFile {
path: PathBuf,
backup_path: PathBuf,
existed_before: bool,
original_sha256: Option<String>,
managed_sha256: String,
}
struct AgentModificationInspection {
enabled: bool,
state: String,
backup_available: bool,
applied_model: Option<String>,
warnings: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AgentClient {
ClaudeCode,
ClaudeDesktop,
Codex,
OpenCode,
OpenClaw,
Hermes,
}
#[derive(Clone, Debug)]
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] // The desktop-app variants are constructed only on macOS/Windows builds.
enum CodexAppTarget {
Application(PathBuf),
#[cfg(target_os = "windows")]
WindowsAppId(String),
}
impl AgentClient {
fn parse(value: &str) -> Result<Self, String> {
match value.trim().to_ascii_lowercase().as_str() {
"claude-code" => Ok(Self::ClaudeCode),
"claude-desktop" => Ok(Self::ClaudeDesktop),
"codex" => Ok(Self::Codex),
"opencode" => Ok(Self::OpenCode),
"openclaw" => Ok(Self::OpenClaw),
"hermes" => Ok(Self::Hermes),
_ => Err(format!("不支持的智能体客户端: {value}")),
}
}
fn id(self) -> &'static str {
match self {
Self::ClaudeCode => "claude-code",
Self::ClaudeDesktop => "claude-desktop",
Self::Codex => "codex",
Self::OpenCode => "opencode",
Self::OpenClaw => "openclaw",
Self::Hermes => "hermes",
}
}
fn name(self) -> &'static str {
match self {
Self::ClaudeCode => "Claude Code",
Self::ClaudeDesktop => "Claude Desktop",
Self::Codex => "Codex",
Self::OpenCode => "OpenCode",
Self::OpenClaw => "OpenClaw",
Self::Hermes => "Hermes Agent",
}
}
fn supported_platform(self) -> bool {
self != Self::ClaudeDesktop
|| cfg!(any(
target_os = "windows",
target_os = "macos",
target_os = "linux"
))
}
fn executable_names(self) -> &'static [&'static str] {
match self {
Self::ClaudeCode => &["claude"],
Self::ClaudeDesktop => &[],
Self::Codex => &["codex"],
Self::OpenCode => &["opencode"],
Self::OpenClaw => &["openclaw"],
Self::Hermes => &["hermes"],
}
}
}
struct AgentFileUpdate {
path: PathBuf,
after: String,
}
type FileSnapshot = (PathBuf, Option<Vec<u8>>);
type AgentRecordExtension = (AgentModificationRecord, Vec<FileSnapshot>);
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GuiNetworkSettings {
port: u16,
allow_lan: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreConfigSettings {
api_keys: Vec<String>,
management_secret_configured: bool,
plugins_enabled: bool,
routing_strategy: String,
// Kept for internal config migration/tests; never exposed to the WebView.
#[allow(dead_code)]
#[serde(skip_serializing)]
management_secret_key: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreApiKeyView {
api_key: String,
remark: String,
built_in: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreConfigView {
api_keys: Vec<CoreApiKeyView>,
management_secret_configured: bool,
plugins_enabled: bool,
routing_strategy: String,
}
impl Default for CoreInstallTask {
fn default() -> Self {
Self {
running: false,
cancellable: false,
phase: "空闲".to_string(),
downloaded: 0,
total: None,
percent: None,
message: None,
result: None,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoreMetadata {
version: String,
asset_name: String,
installed_at_unix: u64,
}
struct DownloadedArchive {
size: u64,
sha256: String,
}
impl CoreDownloadState {
fn start(&self, token: CancellationToken, version: Option<String>) -> Result<(), String> {
let mut inner = self
.inner
.lock()
.map_err(|_| "内核安装状态锁已损坏".to_string())?;
if inner.running {
return Err("已有内核安装任务正在运行".to_string());
}
inner.running = true;
inner.token = Some(token);
inner.task = CoreInstallTask {
running: true,
cancellable: true,
phase: version
.map(|version| format!("准备安装 {version}"))
.unwrap_or_else(|| "准备安装最新版".to_string()),
downloaded: 0,
total: None,
percent: None,
message: None,
result: None,
};
Ok(())
}
fn cancel(&self) {
if let Ok(inner) = self.inner.lock() {
if let Some(token) = &inner.token {
token.cancel();
}
}
}
fn snapshot(&self) -> CoreInstallTask {
self.inner
.lock()
.map(|inner| inner.task.clone())
.unwrap_or_default()
}
fn progress(
&self,
window: &tauri::Window,
phase: &str,
downloaded: u64,
total: Option<u64>,
cancellable: bool,
) {
let percent = total
.filter(|total| *total > 0)
.map(|total| downloaded as f64 * 100.0 / total as f64);
let task = {
let Ok(mut inner) = self.inner.lock() else {
return;
};
inner.task.running = inner.running;
inner.task.cancellable = cancellable;
inner.task.phase = phase.to_string();
inner.task.downloaded = downloaded;
inner.task.total = total;
inner.task.percent = percent;
inner.task.clone()
};
let _ = window.emit(CORE_INSTALL_PROGRESS_EVENT, task);
}
fn finish(&self, window: &tauri::Window, result: Result<CoreInstallResult, String>) {
let task = {
let Ok(mut inner) = self.inner.lock() else {
return;
};
inner.running = false;
inner.token = None;
inner.task.running = false;
inner.task.cancellable = false;
match result {
Ok(result) => {
inner.task.phase = "安装完成".to_string();
inner.task.downloaded = 1;
inner.task.total = Some(1);
inner.task.percent = Some(100.0);
inner.task.message = Some(format!("{} 安装完成", result.version));
inner.task.result = Some(result);
}
Err(error) => {
inner.task.phase = if error.contains("取消") {
"已取消".to_string()
} else {
"安装失败".to_string()
};
inner.task.message = Some(error);
inner.task.result = None;
}
}
inner.task.clone()
};
let _ = window.emit(CORE_INSTALL_PROGRESS_EVENT, task);
}
}
impl CoreProcessState {
fn managed_pid(&self) -> Option<u32> {
let Ok(mut child) = self.child.lock() else {
return None;
};
let process = child.as_mut()?;
if let Ok(None) = process.try_wait() {
return Some(process.id());
}
*child = None;
drop(child);
self.clear_lifetime_guard();
None
}
fn clear_lifetime_guard(&self) {
#[cfg(windows)]
if let Ok(mut job) = self.job.lock() {
if let Some(handle) = job.take() {
close_windows_handle(handle);
}
}
}
fn take_child(&self) -> Option<Child> {
self.child.lock().ok().and_then(|mut child| child.take())
}
fn store_child(&self, child: Child) -> Result<u32, String> {
let pid = child.id();
#[cfg(windows)]
{
let job = attach_child_to_windows_job(&child)?;
let Ok(mut managed_child) = self.child.lock() else {
close_windows_handle(job);
return Err("内核进程状态锁已损坏".to_string());
};
let Ok(mut managed_job) = self.job.lock() else {
close_windows_handle(job);
return Err("内核进程作业状态锁已损坏".to_string());
};
*managed_child = Some(child);
*managed_job = Some(job);
}
#[cfg(not(windows))]
{
let mut managed_child = self
.child
.lock()
.map_err(|_| "内核进程状态锁已损坏".to_string())?;
*managed_child = Some(child);
}
Ok(pid)
}
}
impl GuiConfigState {
fn new(config: GuiConfigFile) -> Self {
Self {
inner: Mutex::new(config),
}
}
fn snapshot(&self) -> Result<GuiConfigFile, String> {
self.inner
.lock()
.map(|config| config.clone())
.map_err(|_| "GUI 配置状态锁已损坏".to_string())
}
fn update_network(&self, port: u16, allow_lan: bool) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.port = port;
config.allow_lan = allow_lan;
Ok(())
})
}
fn set_run_on_startup(&self, run_on_startup: bool) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.run_on_startup = run_on_startup;
Ok(())
})
}
fn set_locale(&self, locale: String) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.locale = normalize_app_locale(&locale).to_string();
Ok(())
})
}
fn set_management_secret_key(&self, secret_key: String) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.management_secret_key = secret_key;
Ok(())
})
}
fn sync_core_settings(&self, settings: &CoreConfigSettings) -> Result<GuiConfigFile, String> {
self.sync_core_settings_with_api_key(settings, None)
}
fn sync_core_settings_with_api_key(
&self,
settings: &CoreConfigSettings,
added_api_key: Option<GuiApiKeyEntry>,
) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.api_keys = merge_core_api_keys_with_gui_metadata(
&config.api_keys,
&settings.api_keys,
added_api_key.as_ref(),
);
config.plugins_enabled = settings.plugins_enabled;
config.routing_strategy = settings.routing_strategy.clone();
Ok(())
})
}
fn update<F>(&self, update: F) -> Result<GuiConfigFile, String>
where
F: FnOnce(&mut GuiConfigFile) -> Result<(), String>,
{
let mut current = self
.inner
.lock()
.map_err(|_| "GUI 配置状态锁已损坏".to_string())?;
let mut config = current.clone();
update(&mut config)?;
write_gui_config(&config)?;
*current = config.clone();
Ok(config)
}
}
impl From<&GuiConfigFile> for GuiSettings {
fn from(config: &GuiConfigFile) -> Self {
Self {
port: config.port,
allow_lan: config.allow_lan,
run_on_startup: config.run_on_startup,
}
}
}
impl From<&GuiConfigFile> for CoreConfigSettings {
fn from(config: &GuiConfigFile) -> Self {
Self {
api_keys: gui_api_key_values(&config.api_keys),
management_secret_configured: !config.management_secret_key.is_empty(),
plugins_enabled: config.plugins_enabled,
routing_strategy: config.routing_strategy.clone(),
management_secret_key: Some(config.management_secret_key.clone()),
}
}
}
impl From<&GuiConfigFile> for CoreConfigView {
fn from(config: &GuiConfigFile) -> Self {
Self {
api_keys: config
.api_keys
.iter()
.map(|entry| CoreApiKeyView {
api_key: entry.key.clone(),
remark: entry.remark.clone(),
built_in: entry.key == DEFAULT_API_KEY,
})
.collect(),
management_secret_configured: !config.management_secret_key.is_empty(),
plugins_enabled: config.plugins_enabled,
routing_strategy: config.routing_strategy.clone(),
}
}
}
#[derive(Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
#[derive(Deserialize)]
struct GithubAsset {
name: String,
browser_download_url: String,
size: Option<u64>,
digest: Option<String>,
}
#[tauri::command]
fn health_check() -> &'static str {
"EasyCLIProxyAPI Rust backend is ready"
}
#[tauri::command]
fn detect_core_platform() -> Result<CorePlatform, String> {
current_core_platform()
}
#[tauri::command]
fn get_core_status(
process_state: tauri::State<'_, CoreProcessState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
current_core_status(Some(process_state.inner()), Some(config.port))
}
fn emit_core_status(app: &tauri::AppHandle, status: &CoreStatus) {
#[cfg(target_os = "windows")]
update_windows_tray_status(app, status);
let _ = app.emit(CORE_STATUS_EVENT, status);
}
#[tauri::command]
fn get_gui_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<GuiSettings, String> {
let config = gui_config_state.snapshot()?;
Ok(GuiSettings::from(&config))
}
#[tauri::command]
fn set_app_locale(
app: tauri::AppHandle,
process_state: tauri::State<'_, CoreProcessState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
locale: String,
) -> Result<String, String> {
let config = gui_config_state.set_locale(locale)?;
#[cfg(target_os = "windows")]
if let Ok(status) = current_core_status(Some(process_state.inner()), Some(config.port)) {
update_windows_tray_locale(&app, &config.locale, &status);
}
#[cfg(not(target_os = "windows"))]
let _ = (app, process_state);
Ok(config.locale)
}
#[tauri::command]
fn resolve_windows_close_request(
app: tauri::AppHandle,
action: WindowsCloseAction,
) -> Result<(), String> {
match action {
WindowsCloseAction::Exit => app.exit(0),
WindowsCloseAction::MinimizeToTray => {
let window = app
.get_webview_window("main")
.ok_or_else(|| "主窗口不存在".to_string())?;
window
.hide()
.map_err(|error| format!("隐藏主窗口失败: {error}"))?;
}
}
Ok(())
}
fn inspect_agent_config_statuses(
app: &tauri::AppHandle,
port: u16,
) -> Result<Vec<AgentConfigStatus>, String> {
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
Ok([
AgentClient::ClaudeCode,
AgentClient::ClaudeDesktop,
AgentClient::Codex,
AgentClient::OpenCode,
AgentClient::OpenClaw,
AgentClient::Hermes,
]
.into_iter()
.map(|client| inspect_agent_config(client, &home, port))
.collect())
}
fn refresh_agent_config_status_cache(
app: &tauri::AppHandle,
gui_config_state: &GuiConfigState,
cache: &AgentConfigStatusCache,
) -> Result<Vec<AgentConfigStatus>, String> {
let _refresh_guard = cache
.refresh_lock
.lock()
.map_err(|_| "智能体配置状态刷新锁已损坏".to_string())?;
let port = gui_config_state.snapshot()?.port;
let statuses = inspect_agent_config_statuses(app, port)?;
cache.replace(port, statuses.clone())?;
Ok(statuses)
}
#[tauri::command]
fn get_agent_config_statuses(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
cache: tauri::State<'_, AgentConfigStatusCache>,
) -> Result<Vec<AgentConfigStatus>, String> {
{
let _refresh_guard = cache
.refresh_lock
.lock()
.map_err(|_| "智能体配置状态刷新锁已损坏".to_string())?;
let port = gui_config_state.snapshot()?.port;
if let Some(statuses) = cache.get(port)? {
return Ok(statuses);
}
}
refresh_agent_config_status_cache(&app, gui_config_state.inner(), cache.inner())
}
#[tauri::command]
fn refresh_agent_config_statuses(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
cache: tauri::State<'_, AgentConfigStatusCache>,
) -> Result<Vec<AgentConfigStatus>, String> {
refresh_agent_config_status_cache(&app, gui_config_state.inner(), cache.inner())
}
#[tauri::command]
async fn get_agent_models(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<Vec<AgentModelOption>, String> {
let config = gui_config_state.snapshot()?;
fetch_agent_models(config.port).await
}
#[tauri::command]
async fn get_thinking_aliases(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let content = fetch_management_config_yaml(&config).await?;
thinking_aliases_from_yaml(&content)
}
#[tauri::command]
async fn get_thinking_alias_sources(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<Vec<ThinkingAliasSource>, String> {
let config = gui_config_state.snapshot()?;
let content = fetch_management_config_yaml(&config).await?;
let available_models = fetch_agent_models(config.port).await?;
let definitions = fetch_codex_model_definitions(&config)
.await
.unwrap_or_default();
Ok(
resolved_thinking_alias_sources(&content, &definitions, &available_models)?
.into_iter()
.map(|resolved| resolved.source)
.collect(),
)
}
#[tauri::command]
async fn create_thinking_alias(
gui_config_state: tauri::State<'_, GuiConfigState>,
source_id: String,
alias: String,
effort: String,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let source_id = source_id.trim().to_string();
if source_id.is_empty() {
return Err("请先选择原模型".to_string());
}
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let effort = validate_thinking_alias_effort(&effort)?;
let content = fetch_management_config_yaml(&config).await?;
let available_models = fetch_agent_models(config.port).await?;
let definitions = fetch_codex_model_definitions(&config)
.await
.unwrap_or_default();
let sources = resolved_thinking_alias_sources(&content, &definitions, &available_models)?;
let source = sources
.iter()
.find(|source| source.source.id == source_id)
.cloned()
.ok_or_else(|| {
"原模型已不在内核当前可用模型中,或其配置来源已经变化,请刷新后重新选择".to_string()
})?;
if source.source.model.eq_ignore_ascii_case(&alias) {
return Err("别名模型不能和原模型相同".to_string());
}
if available_models
.iter()
.any(|model| model.name.eq_ignore_ascii_case(&alias))
{
return Err(format!("{alias} 已经是实际模型 ID,不能再作为别名"));
}
let document = serde_norway::from_str::<serde_norway::Value>(&content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
if configured_model_alias_exists(root, &alias) {
return Err(format!("别名模型 {alias} 已存在"));
}
let updated = add_thinking_alias_to_yaml(&content, &source, &alias, &effort)?;
put_management_config_yaml(&config, &updated).await?;
thinking_aliases_from_yaml(&updated)
}
#[tauri::command]
async fn delete_thinking_alias(
gui_config_state: tauri::State<'_, GuiConfigState>,
alias: String,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let content = fetch_management_config_yaml(&config).await?;
let updated = remove_thinking_alias_from_yaml(&content, &alias)?;
put_management_config_yaml(&config, &updated).await?;
thinking_aliases_from_yaml(&updated)
}
async fn fetch_agent_models(port: u16) -> Result<Vec<AgentModelOption>, String> {
if port == 0 {
return Err("内核端口无效".to_string());
}
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(3))
.timeout(Duration::from_secs(15))
.build()
.map_err(|error| format!("创建模型列表客户端失败: {error}"))?;
let base_url = format!("http://127.0.0.1:{port}");
let endpoints = [
format!("{base_url}/v1/models"),
format!("{base_url}/models"),
];
for (index, endpoint) in endpoints.iter().enumerate() {
let response = client
.get(endpoint)
.bearer_auth(DEFAULT_API_KEY)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|error| format!("请求本机模型列表失败: {error}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| format!("读取本机模型列表失败: {error}"))?;
if status.is_success() {
let payload = serde_json::from_str::<serde_json::Value>(&body).map_err(|error| {
format!(
"解析本机模型列表失败: {error}; body={}",
truncate_for_error(&body)
)
})?;
return parse_agent_model_options(&payload);
}
let can_try_legacy_path = index == 0 && matches!(status.as_u16(), 404 | 405);
if !can_try_legacy_path {
return Err(format_agent_models_error(status.as_u16(), &body));
}
}
Err("本机内核不支持模型列表接口".to_string())
}
#[tauri::command]
async fn set_agent_config_enabled(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
model: String,
enabled: bool,
force_restore: bool,
) -> Result<AgentConfigActionResult, String> {
let client = AgentClient::parse(&client)?;
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let config = gui_config_state.snapshot()?;
let port = config.port;
if enabled {
validate_agent_can_enable(client, &home, port)?;
let available_models = fetch_agent_models(port).await?;
let model =
resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?;
let codex_models = if client == AgentClient::Codex {
Some(fetch_codex_model_catalog_specs(&config, &available_models).await)
} else {
None
};
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
enable_agent_modification(
client,
&home,
port,
&model,
&available_models,
codex_models.as_deref(),
)
} else {
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
disable_agent_modification(client, &home, port, force_restore)
}
}
#[tauri::command]
async fn update_agent_config(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
model: String,
) -> Result<AgentConfigActionResult, String> {
let client = AgentClient::parse(&client)?;
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let config = gui_config_state.snapshot()?;
let port = config.port;
let available_models = fetch_agent_models(port).await?;
let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?;
let codex_models = if client == AgentClient::Codex {
Some(fetch_codex_model_catalog_specs(&config, &available_models).await)
} else {
None
};
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
update_agent_modification(
client,
&home,
port,
&model,
&available_models,
codex_models.as_deref(),
)
}
#[tauri::command]
fn launch_agent(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
target: Option<String>,
) -> Result<(), String> {
let client = AgentClient::parse(&client)?;
if !client.supported_platform() {
return Err(format!("当前平台不支持启动 {}", client.name()));
}
let home = app
.path()
.home_dir()
.map_err(|error| format!("无法获取用户目录: {error}"))?;
let port = gui_config_state.snapshot()?.port;
let status = inspect_agent_config(client, &home, port);
validate_agent_launch_modification(
client,
status.modification_enabled,
&status.modification_state,
)?;
let requested_target = target
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if client == AgentClient::Codex && requested_target != Some("cli") {
if let Some(app_target) = find_codex_app_target(&home) {
return launch_codex_app(&app_target);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
if let Some(codex_cli) = find_agent_executable(AgentClient::Codex, &home) {
if launch_codex_app_via_cli(&codex_cli, &home).is_ok() {
return Ok(());
}
}
if requested_target == Some("app") {
return Err("未检测到 ChatGPT App,请重新检测或改用 Codex CLI".to_string());
}
}
if requested_target.is_some_and(|value| value != "cli" && value != "app") {
return Err("不支持的智能体启动方式".to_string());
}
if client == AgentClient::ClaudeDesktop && requested_target == Some("cli") {
return Err("Claude Desktop 不支持 CLI 启动方式".to_string());
}
if client != AgentClient::Codex
&& client != AgentClient::ClaudeDesktop
&& requested_target == Some("app")
{
return Err(format!("{} 不支持桌面 App 启动方式", client.name()));
}
let executable = find_agent_executable(client, &home)
.ok_or_else(|| format!("未找到 {} 的可执行文件", client.name()))?;
if client == AgentClient::ClaudeDesktop {
launch_desktop_agent(&executable, client.name())
} else {
launch_cli_agent(&executable, client.name(), &home)
}
}
fn validate_agent_launch_modification(
client: AgentClient,
enabled: bool,
state: &str,
) -> Result<(), String> {
if enabled && state == AGENT_PHASE_ACTIVE {
return Ok(());
}
Err(format!(
"请先为 {} 开启“修改智能体配置”,确保 CPA 配置生效后再启动",
client.name()
))
}
fn validate_agent_can_enable(client: AgentClient, home: &Path, port: u16) -> Result<(), String> {
if !client.supported_platform() {
return Err(format!(
"{} 当前仅支持在 Windows、macOS 和 Linux 上配置",
client.name()
));
}
let detection = inspect_agent_config(client, home, port);
if !detection.installed {
return Err(format!("未检测到 {},请先安装后再配置", client.name()));
}
if !detection.config_valid {
return Err(detection
.error
.unwrap_or_else(|| "原配置格式异常,请先修复后再开启".to_string()));
}
Ok(())
}
fn validate_agent_model(value: &str) -> Result<String, String> {
let model = value.trim();
if model.is_empty() {
return Err("请先选择模型".to_string());
}
if model.len() > 240 || model.chars().any(char::is_control) {
return Err("模型名称格式无效".to_string());
}
Ok(model.to_string())
}
fn resolve_available_agent_model(
models: &[AgentModelOption],
model: &str,
) -> Result<String, String> {
if models.is_empty() {
return Err("当前内核没有可选模型,无法修改智能体配置".to_string());
}
models
.iter()
.find(|available| available.name.eq_ignore_ascii_case(model))
.map(|available| available.name.clone())
.ok_or_else(|| format!("模型 {model} 不在当前可用模型列表中,请刷新后重新选择"))
}
fn parse_agent_model_options(payload: &serde_json::Value) -> Result<Vec<AgentModelOption>, String> {
let source = payload
.as_array()
.or_else(|| payload.get("data").and_then(serde_json::Value::as_array))
.or_else(|| payload.get("models").and_then(serde_json::Value::as_array))
.ok_or_else(|| "本机模型列表响应缺少 data 或 models 数组".to_string())?;
let mut models = Vec::new();
for item in source {
let name = if let Some(name) = item.as_str() {
name.trim().to_string()
} else {
["id", "name", "model", "value"]
.into_iter()
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
.unwrap_or_default()
.trim()
.to_string()
};
if name.is_empty()
|| models
.iter()
.any(|model: &AgentModelOption| model.name.eq_ignore_ascii_case(&name))
{
continue;
}
let alias = ["alias", "display_name", "displayName"]
.into_iter()
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(&name))
.map(str::to_string);
models.push(AgentModelOption { name, alias });
}
Ok(models)
}
async fn fetch_codex_model_catalog_specs(
config: &GuiConfigFile,
models: &[AgentModelOption],
) -> Vec<CodexModelCatalogSpec> {
let definitions = match fetch_codex_model_definitions(config).await {
Ok(definitions) => definitions,
Err(error) => {
eprintln!("读取 CPA Codex 模型定义失败,将使用默认模型参数: {error}");
Vec::new()
}
};
merge_codex_model_catalog_specs(models, &definitions)
}
fn merge_codex_model_catalog_specs(
models: &[AgentModelOption],
definitions: &[CodexModelDefinition],
) -> Vec<CodexModelCatalogSpec> {
models
.iter()
.map(|model| {
let definition = definitions
.iter()
.find(|definition| definition.id.eq_ignore_ascii_case(&model.name));
let reasoning_levels = definition
.map(|definition| definition.reasoning_levels.clone())
.filter(|levels| !levels.is_empty())
.unwrap_or_else(default_codex_reasoning_levels);
CodexModelCatalogSpec {
id: model.name.clone(),
display_name: definition
.and_then(|definition| definition.display_name.clone())
.or_else(|| model.alias.clone())
.unwrap_or_else(|| model.name.clone()),
description: definition
.and_then(|definition| definition.description.clone())
.unwrap_or_else(|| format!("由 CPA 提供的模型 {}", model.name)),
context_window: definition
.and_then(|definition| definition.context_window)
.unwrap_or(DEFAULT_CODEX_CONTEXT_WINDOW),
reasoning_levels,
supports_parallel_tool_calls: definition
.and_then(|definition| definition.supports_tools)
.unwrap_or(true),
}
})
.collect()
}
fn default_codex_reasoning_levels() -> Vec<String> {
["low", "medium", "high", "xhigh"]
.into_iter()
.map(str::to_string)
.collect()
}
fn parse_codex_model_definitions(
payload: &serde_json::Value,
) -> Result<Vec<CodexModelDefinition>, String> {
let source = payload
.as_array()
.or_else(|| payload.get("models").and_then(serde_json::Value::as_array))
.or_else(|| payload.get("data").and_then(serde_json::Value::as_array))
.ok_or_else(|| "Codex 模型定义响应缺少 models 或 data 数组".to_string())?;
let mut definitions = Vec::new();
for item in source {
let id = ["id", "ID", "name"]
.into_iter()
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(id) = id else {
continue;
};
if definitions
.iter()
.any(|definition: &CodexModelDefinition| definition.id.eq_ignore_ascii_case(id))
{
continue;
}
let display_name = ["display_name", "displayName", "DisplayName"]
.into_iter()
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let description = item
.get("description")
.or_else(|| item.get("Description"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let context_window = ["context_length", "contextLength", "ContextLength"]
.into_iter()
.find_map(|key| item.get(key).and_then(json_positive_u64));
let reasoning_levels = item
.get("thinking")
.or_else(|| item.get("Thinking"))
.and_then(|thinking| thinking.get("levels").or_else(|| thinking.get("Levels")))
.and_then(serde_json::Value::as_array)
.map(|levels| {
levels
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.filter(|level| is_codex_reasoning_level(level))
.fold(Vec::new(), |mut result, level| {
if !result.contains(&level) {
result.push(level);
}
result
})
})
.unwrap_or_default();
let supports_tools = item
.get("supported_parameters")
.or_else(|| item.get("supportedParameters"))
.or_else(|| item.get("SupportedParameters"))
.and_then(serde_json::Value::as_array)
.map(|parameters| {
parameters.iter().any(|parameter| {
parameter
.as_str()
.is_some_and(|value| value.eq_ignore_ascii_case("tools"))
})
});
definitions.push(CodexModelDefinition {
id: id.to_string(),
display_name,
description,
context_window,
reasoning_levels,
supports_tools,
});
}
Ok(definitions)
}
fn json_positive_u64(value: &serde_json::Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_str()?.trim().parse::<u64>().ok())
.filter(|value| *value > 0)
}
fn is_codex_reasoning_level(value: &str) -> bool {
matches!(
value,
"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"
)
}
fn format_agent_models_error(status: u16, body: &str) -> String {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
let message = value
.get("error")
.and_then(|error| {
error
.as_str()
.or_else(|| error.get("message").and_then(serde_json::Value::as_str))
})
.or_else(|| value.get("message").and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|message| !message.is_empty());
if let Some(message) = message {
return format!("获取本机模型列表失败 ({status}): {message}");
}
}
let body = body.trim();
if body.is_empty() {
format!("获取本机模型列表失败 ({status})")
} else {
format!(
"获取本机模型列表失败 ({status}): {}",
truncate_for_error(body)
)
}
}
fn agent_config_paths(client: AgentClient, home: &Path) -> Vec<PathBuf> {
match client {
AgentClient::ClaudeCode => {
let directory = home.join(".claude");
let settings = directory.join("settings.json");
let legacy = directory.join("claude.json");
let settings_state_exists = agent_state_path(std::slice::from_ref(&settings))
.map(|path| path.exists())
.unwrap_or(false);
let legacy_state_exists = agent_state_path(std::slice::from_ref(&legacy))
.map(|path| path.exists())
.unwrap_or(false);
vec![if settings_state_exists {
settings
} else if legacy_state_exists || (!settings.exists() && legacy.exists()) {
legacy
} else {
settings
}]
}
AgentClient::ClaudeDesktop => claude_desktop_config_paths(home),
AgentClient::Codex => vec![home.join(".codex/config.toml")],
AgentClient::OpenCode => vec![home.join(".config/opencode/opencode.json")],
AgentClient::OpenClaw => vec![home.join(".openclaw/openclaw.json")],
AgentClient::Hermes => vec![hermes_agent_config_path(home)],
}
}
fn codex_model_catalog_path(home: &Path) -> PathBuf {
home.join(".codex").join(CODEX_MODEL_CATALOG_FILE)
}
fn expected_agent_record_paths(client: AgentClient, paths: &[PathBuf]) -> Vec<PathBuf> {
if client == AgentClient::Codex && paths.len() == 1 {
let mut expected = paths.to_vec();
expected.push(paths[0].with_file_name(CODEX_MODEL_CATALOG_FILE));
expected
} else {
paths.to_vec()
}
}
fn claude_desktop_config_paths(_home: &Path) -> Vec<PathBuf> {
#[cfg(target_os = "macos")]
let (normal, threep) = {
let support = _home.join("Library/Application Support");
(support.join("Claude"), support.join("Claude-3p"))
};
#[cfg(target_os = "windows")]
let (normal, threep) = {
let local = env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| _home.join("AppData/Local"));
(local.join("Claude"), local.join("Claude-3p"))
};
#[cfg(target_os = "linux")]
let (normal, threep) = {
let config_home = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| _home.join(".config"));
(config_home.join("Claude"), config_home.join("Claude-3p"))
};
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Vec::new();
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
let library = threep.join("configLibrary");
vec![
normal.join("claude_desktop_config.json"),
threep.join("claude_desktop_config.json"),
library.join(format!("{CLAUDE_DESKTOP_PROFILE_ID}.json")),
library.join("_meta.json"),
]
}
}
fn hermes_agent_config_path(home: &Path) -> PathBuf {
if let Some(directory) = env::var_os("HERMES_HOME")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
{
return directory.join("config.yaml");
}
#[cfg(target_os = "windows")]
{
return env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"))
.join("hermes/config.yaml");
}
#[cfg(not(target_os = "windows"))]
home.join(".hermes/config.yaml")
}
fn inspect_agent_config(client: AgentClient, home: &Path, port: u16) -> AgentConfigStatus {
let paths = agent_config_paths(client, home);
let config_exists = paths.iter().any(|path| path.is_file());
let result = inspect_agent_managed_config(client, &paths, port);
let (configured, current_model, config_valid, error) = match result {
Ok((configured, model)) => (configured, model, true, None),
Err(error) => (false, None, false, Some(error)),
};
let launch_targets = agent_launch_targets(client, home);
let executable = find_agent_executable(client, home);
let installed = !launch_targets.is_empty() || config_exists;
let version = executable
.as_deref()
.filter(|_| client != AgentClient::ClaudeDesktop)
.and_then(read_agent_version);
let mut warnings = Vec::new();
if !client.supported_platform() {
warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string());
} else if launch_targets.is_empty() && config_exists {
warnings.push("只检测到配置文件,未在 PATH 中找到客户端命令".to_string());
}
if let Some(message) = error.as_ref() {
warnings.push(message.clone());
}
let modification = inspect_agent_modification(
client,
home,
port,
agent_has_managed_marker(client, &paths).unwrap_or(configured),
current_model.as_deref(),
);
warnings.extend(modification.warnings.iter().cloned());
AgentConfigStatus {
id: client.id().to_string(),
name: client.name().to_string(),
supported_platform: client.supported_platform(),
installed,
executable_path: launch_targets.first().map(|target| target.detail.clone()),
launch_targets,
version,
config_paths: paths.iter().map(|path| path_to_string(path)).collect(),
config_exists,
config_valid,
configured,
current_model,
modification_enabled: modification.enabled,
modification_state: modification.state,
backup_available: modification.backup_available,
applied_model: modification.applied_model,
warnings,
error,
}
}
fn inspect_agent_managed_config(
client: AgentClient,
paths: &[PathBuf],
port: u16,
) -> Result<(bool, Option<String>), String> {
match client {
AgentClient::ClaudeCode => inspect_claude_agent_config(&paths[0], port),
AgentClient::ClaudeDesktop if client.supported_platform() => {
inspect_claude_desktop_agent_config(paths, port)
}
AgentClient::ClaudeDesktop => Ok((false, None)),
AgentClient::Codex => inspect_codex_agent_config(&paths[0], port),
AgentClient::OpenCode => inspect_opencode_agent_config(&paths[0], port),
AgentClient::OpenClaw => inspect_openclaw_agent_config(&paths[0], port),
AgentClient::Hermes => inspect_hermes_agent_config(&paths[0], port),
}
}
fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result<bool, String> {
match client {
AgentClient::ClaudeCode => {
if !paths[0].is_file() {
return Ok(false);
}
let root = read_agent_json_or_empty(&paths[0], "Claude Code 配置")?;
let env = root.get("env");
Ok(env
.and_then(|value| value.get("ANTHROPIC_BASE_URL"))
.and_then(serde_json::Value::as_str)
.is_some_and(|value| {
value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:")
})
&& env
.and_then(|value| value.get("ANTHROPIC_AUTH_TOKEN"))
.and_then(serde_json::Value::as_str)
== Some(DEFAULT_API_KEY))
}
AgentClient::ClaudeDesktop => {
if paths.len() != 4 || !paths[3].is_file() {
return Ok(false);
}
let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?;
Ok(meta.get("appliedId").and_then(serde_json::Value::as_str)
== Some(CLAUDE_DESKTOP_PROFILE_ID))
}
AgentClient::Codex => {
if !paths[0].is_file() {
return Ok(false);
}
let root: toml::Value = toml::from_str(
&fs::read_to_string(&paths[0])
.map_err(|error| format!("读取 Codex 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 Codex 配置失败: {error}"))?;
Ok(root.get("model_provider").and_then(toml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID))
}
AgentClient::OpenCode => {
if !paths[0].is_file() {
return Ok(false);
}
let root = read_agent_json_or_empty(&paths[0], "OpenCode 配置")?;
Ok(root
.get("provider")
.and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID))
.is_some())
}
AgentClient::OpenClaw => {
if !paths[0].is_file() {
return Ok(false);
}
let root: serde_json::Value = json5::from_str(
&fs::read_to_string(&paths[0])
.map_err(|error| format!("读取 OpenClaw 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 OpenClaw 配置失败: {error}"))?;
Ok(root
.get("models")
.and_then(|value| value.get("providers"))
.and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID))
.is_some())
}
AgentClient::Hermes => {
if !paths[0].is_file() {
return Ok(false);
}
let root: serde_yaml::Value = serde_yaml::from_str(
&fs::read_to_string(&paths[0])
.map_err(|error| format!("读取 Hermes 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 Hermes 配置失败: {error}"))?;
Ok(root
.get("custom_providers")
.and_then(serde_yaml::Value::as_sequence)
.is_some_and(|providers| {
providers.iter().any(|provider| {
provider.get("name").and_then(serde_yaml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
})
}))
}
}
}
fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec<AgentLaunchTarget> {
let mut targets = Vec::new();
if client == AgentClient::Codex {
#[cfg(any(target_os = "macos", target_os = "windows"))]
if let Some(target) = find_codex_app_target(home) {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "ChatGPT App".to_string(),
detail: codex_app_target_detail(&target),
});
} else if let Some(executable) = find_agent_executable(AgentClient::Codex, home) {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "ChatGPT App".to_string(),
detail: format!("{} app", path_to_string(&executable)),
});
}
}
if let Some(executable) = find_agent_executable(client, home) {
targets.push(AgentLaunchTarget {
id: if client == AgentClient::ClaudeDesktop {
"app".to_string()
} else {
"cli".to_string()
},
label: match client {
AgentClient::ClaudeDesktop => "Claude Desktop".to_string(),
AgentClient::Codex => "Codex CLI".to_string(),
_ => client.name().to_string(),
},
detail: path_to_string(&executable),
});
}
targets
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn codex_app_target_detail(target: &CodexAppTarget) -> String {
match target {
CodexAppTarget::Application(path) => path_to_string(path),
#[cfg(target_os = "windows")]
CodexAppTarget::WindowsAppId(app_id) => format!("Microsoft Store · {app_id}"),
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn launch_codex_app_via_cli(executable: &Path, home: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
let mut command = windows_command_for_executable(executable, false);
#[cfg(not(target_os = "windows"))]
let mut command = Command::new(executable);
command
.arg("app")
.arg(home)
.current_dir(home)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
command
.spawn()
.map(|_| ())
.map_err(|error| format!("通过 Codex CLI 启动 ChatGPT App 失败: {error}"))
}
fn find_codex_app_target(home: &Path) -> Option<CodexAppTarget> {
#[cfg(target_os = "macos")]
{
[PathBuf::from("/Applications"), home.join("Applications")]
.into_iter()
.flat_map(|directory| {
[
"ChatGPT.app",
"Codex.app",
"OpenAI Codex.app",
"OpenAI.Codex.app",
]
.into_iter()
.map(move |name| directory.join(name))
})
.find(|path| path.is_dir())
.map(CodexAppTarget::Application)
}
#[cfg(target_os = "windows")]
{
return find_windows_codex_app_target(home);
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = home;
None
}
}
#[cfg(target_os = "windows")]
fn windows_system_root() -> PathBuf {
env::var_os("SystemRoot")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"))
}
#[cfg(target_os = "windows")]
fn windows_powershell_executable() -> PathBuf {
let executable = windows_system_root().join("System32/WindowsPowerShell/v1.0/powershell.exe");
if executable.is_file() {
executable
} else {
PathBuf::from("powershell.exe")
}
}
#[cfg(target_os = "windows")]
fn windows_explorer_executable() -> PathBuf {
let executable = windows_system_root().join("explorer.exe");
if executable.is_file() {
executable
} else {
PathBuf::from("explorer.exe")
}
}
#[cfg(target_os = "windows")]
fn windows_registry_executable() -> PathBuf {
let executable = windows_system_root().join("System32/reg.exe");
if executable.is_file() {
executable
} else {
PathBuf::from("reg.exe")
}
}
#[cfg(target_os = "windows")]
fn windows_command_processor() -> PathBuf {
env::var_os("ComSpec")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| windows_system_root().join("System32/cmd.exe"))
}
#[cfg(target_os = "windows")]
fn find_windows_codex_app_target(home: &Path) -> Option<CodexAppTarget> {
find_windows_registered_codex_app_target()
.or_else(|| find_windows_codex_app_id_via_registry().map(CodexAppTarget::WindowsAppId))
.or_else(|| find_windows_codex_app_executable(home).map(CodexAppTarget::Application))
}
#[cfg(target_os = "windows")]
fn find_windows_registered_codex_app_target() -> Option<CodexAppTarget> {
const DISCOVERY_SCRIPT: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
$startApps = @(Get-StartApps)
$appId = $startApps |
Where-Object {
$_.AppID -like 'OpenAI.Codex_*!*' -or
$_.AppID -like 'OpenAI.CodexBeta_*!*' -or
$_.AppID -like 'OpenAI.ChatGPT_*!*'
} |
Select-Object -First 1 -ExpandProperty AppID
if ($appId) {
Write-Output "APPID:$appId"
exit 0
}
$packages = @(Get-AppxPackage) |
Where-Object {
$_.Name -in @('OpenAI.Codex', 'OpenAI.CodexBeta', 'OpenAI.ChatGPT') -or
$_.PackageFamilyName -match '^OpenAI\.(Codex|CodexBeta|ChatGPT)_'
}
foreach ($package in $packages) {
$manifest = Get-AppxPackageManifest -Package $package.PackageFullName
$application = @($manifest.Package.Applications.Application) |
Where-Object { $_.Id } |
Select-Object -First 1
if ($application -and $package.PackageFamilyName) {
Write-Output "APPID:$($package.PackageFamilyName)!$($application.Id)"
exit 0
}
}
$appPathKeys = @(
'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\ChatGPT.exe',
'HKLM:\Software\Microsoft\Windows\CurrentVersion\App Paths\ChatGPT.exe',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\ChatGPT.exe'
)
foreach ($key in $appPathKeys) {
if (-not (Test-Path -LiteralPath $key)) { continue }
$candidate = (Get-Item -LiteralPath $key).GetValue('')
if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) {
Write-Output "EXE:$candidate"
exit 0
}
}
$shortcutRoots = @(
"$env:APPDATA\Microsoft\Windows\Start Menu\Programs",
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs"
)
$shell = New-Object -ComObject WScript.Shell
foreach ($shortcutFile in (Get-ChildItem -LiteralPath $shortcutRoots -Filter '*.lnk' -Recurse)) {
$shortcut = $shell.CreateShortcut($shortcutFile.FullName)
$target = $shortcut.TargetPath
if (-not $target -or -not (Test-Path -LiteralPath $target -PathType Leaf)) { continue }
$fileName = [System.IO.Path]::GetFileName($target)
if ($fileName -ieq 'Codex.exe' -and $target -match '(?i)\\(bin|node_modules|\.vscode\\extensions)\\') {
continue
}
if ($fileName -ieq 'ChatGPT.exe' -or $fileName -ieq 'Codex.exe') {
Write-Output "EXE:$target"
exit 0
}
}
"#;
let encoded_command = windows_powershell_encoded_command(DISCOVERY_SCRIPT);
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded_command,
]);
configure_background_command(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout)).and_then(
|target| match &target {
CodexAppTarget::Application(path) if !path.is_file() => None,
_ => Some(target),
},
)
}
#[cfg(target_os = "windows")]
fn windows_powershell_encoded_command(script: &str) -> String {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let bytes = script
.encode_utf16()
.flat_map(|unit| unit.to_le_bytes())
.collect::<Vec<_>>();
STANDARD.encode(bytes)
}
#[cfg(target_os = "windows")]
fn parse_windows_codex_app_discovery_output(output: &str) -> Option<CodexAppTarget> {
output.lines().find_map(|line| {
let line = line.trim();
if let Some(app_id) = line.strip_prefix("APPID:").map(str::trim) {
return (!app_id.is_empty()).then(|| CodexAppTarget::WindowsAppId(app_id.to_string()));
}
line.strip_prefix("EXE:")
.map(str::trim)
.filter(|path| !path.is_empty())
.map(|path| CodexAppTarget::Application(PathBuf::from(path)))
})
}
#[cfg(target_os = "windows")]
fn find_windows_codex_app_id_via_registry() -> Option<String> {
const PACKAGES_KEY: &str = r"HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
for package_name in ["OpenAI.Codex_", "OpenAI.CodexBeta_", "OpenAI.ChatGPT_"] {
let mut command = Command::new(windows_registry_executable());
command.args(["query", PACKAGES_KEY, "/f", package_name, "/k", "/s"]);
configure_background_command(&mut command);
let Ok(output) = command.output() else {
continue;
};
if !output.status.success() {
continue;
}
if let Some(app_id) =
parse_windows_codex_app_id_from_registry(&String::from_utf8_lossy(&output.stdout))
{
return Some(app_id);
}
}
None
}
#[cfg(target_os = "windows")]
fn parse_windows_codex_app_id_from_registry(output: &str) -> Option<String> {
const PACKAGE_MARKER: &str = "\\AppModel\\Repository\\Packages\\";
output.lines().find_map(|line| {
let line = line.trim();
let (_, package_full_name) = line.split_once(PACKAGE_MARKER)?;
if package_full_name.contains('\\') {
return None;
}
windows_codex_app_id_from_package_full_name(package_full_name)
})
}
#[cfg(target_os = "windows")]
fn windows_codex_app_id_from_package_full_name(package_full_name: &str) -> Option<String> {
let package_name = package_full_name.split('_').next()?.trim();
if !matches!(
package_name,
"OpenAI.Codex" | "OpenAI.CodexBeta" | "OpenAI.ChatGPT"
) {
return None;
}
let publisher_id = package_full_name.rsplit('_').next()?.trim();
if publisher_id.is_empty() || publisher_id == package_name {
return None;
}
Some(format!("{package_name}_{publisher_id}!App"))
}
#[cfg(target_os = "windows")]
fn find_windows_codex_app_executable(home: &Path) -> Option<PathBuf> {
let local = env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"));
let mut candidates = vec![
local.join("Programs/OpenAI/ChatGPT/ChatGPT.exe"),
local.join("Programs/ChatGPT/ChatGPT.exe"),
local.join("OpenAI/ChatGPT/ChatGPT.exe"),
local.join("OpenAI/Codex/Codex.exe"),
local.join("Programs/OpenAI/Codex/Codex.exe"),
local.join("Programs/Codex/Codex.exe"),
local.join("Microsoft/WindowsApps/ChatGPT.exe"),
];
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
let Some(root) = env::var_os(variable)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
else {
continue;
};
candidates.extend([
root.join("OpenAI/ChatGPT/ChatGPT.exe"),
root.join("ChatGPT/ChatGPT.exe"),
root.join("OpenAI/Codex/Codex.exe"),
root.join("Codex/Codex.exe"),
]);
}
candidates.into_iter().find(|path| path.is_file())
}
#[cfg(target_os = "macos")]
fn launch_codex_app(target: &CodexAppTarget) -> Result<(), String> {
let CodexAppTarget::Application(application) = target;
Command::new("open")
.arg(application)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|error| format!("启动 ChatGPT App 失败: {error}"))
}
#[cfg(target_os = "windows")]
fn launch_codex_app(target: &CodexAppTarget) -> Result<(), String> {
let mut command = match target {
CodexAppTarget::Application(executable) => Command::new(executable),
CodexAppTarget::WindowsAppId(app_id) => {
let mut command = Command::new(windows_explorer_executable());
command.arg(format!("shell:AppsFolder\\{app_id}"));
command
}
};
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
command
.spawn()
.map(|_| ())
.map_err(|error| format!("启动 ChatGPT App 失败: {error}"))
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn launch_codex_app(_target: &CodexAppTarget) -> Result<(), String> {
Err("当前平台不支持 ChatGPT App".to_string())
}
fn find_agent_executable(client: AgentClient, home: &Path) -> Option<PathBuf> {
if client == AgentClient::ClaudeDesktop {
return find_claude_desktop_executable(home);
}
for directory in agent_executable_directories(home) {
for name in client.executable_names() {
#[cfg(target_os = "windows")]
let candidates = [
directory.join(format!("{name}.exe")),
directory.join(format!("{name}.cmd")),
directory.join(format!("{name}.bat")),
directory.join(name),
];
#[cfg(not(target_os = "windows"))]
let candidates = [directory.join(name)];
if let Some(candidate) = candidates.into_iter().find(|path| path.is_file()) {
return Some(candidate);
}
}
}
None
}
fn agent_executable_directories(home: &Path) -> Vec<PathBuf> {
let mut directories = Vec::new();
let mut push = |path: PathBuf| {
if !path.as_os_str().is_empty() && !directories.iter().any(|item| item == &path) {
directories.push(path);
}
};
if let Some(path) = env::var_os("PATH") {
env::split_paths(&path).for_each(&mut push);
}
[
home.join(".local/bin"),
home.join(".npm-global/bin"),
home.join(".bun/bin"),
home.join(".cargo/bin"),
home.join("bin"),
]
.into_iter()
.for_each(&mut push);
for variable in ["PNPM_HOME", "BUN_INSTALL", "NPM_CONFIG_PREFIX"] {
if let Some(path) = env::var_os(variable) {
let path = PathBuf::from(path);
push(
if variable == "BUN_INSTALL" || variable == "NPM_CONFIG_PREFIX" {
path.join("bin")
} else {
path
},
);
}
}
for root in [
home.join(".nvm/versions/node"),
home.join(".local/state/fnm_multishells"),
] {
if let Ok(entries) = fs::read_dir(root) {
for entry in entries.flatten() {
push(entry.path().join("bin"));
}
}
}
#[cfg(unix)]
for path in ["/usr/local/bin", "/usr/bin", "/bin"] {
push(PathBuf::from(path));
}
#[cfg(target_os = "macos")]
push(PathBuf::from("/opt/homebrew/bin"));
#[cfg(target_os = "windows")]
{
if let Some(app_data) = env::var_os("APPDATA") {
push(PathBuf::from(app_data).join("npm"));
}
if let Some(local_app_data) = env::var_os("LOCALAPPDATA") {
push(PathBuf::from(local_app_data).join("Microsoft/WindowsApps"));
}
}
directories
}
fn find_claude_desktop_executable(home: &Path) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
[
PathBuf::from("/Applications/Claude.app/Contents/MacOS/Claude"),
home.join("Applications/Claude.app/Contents/MacOS/Claude"),
]
.into_iter()
.find(|path| path.is_file())
}
#[cfg(target_os = "windows")]
{
let local = env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"));
return [
local.join("Programs/Claude/Claude.exe"),
local.join("Claude/Claude.exe"),
]
.into_iter()
.find(|path| path.is_file());
}
#[cfg(target_os = "linux")]
{
let mut candidates = agent_executable_directories(home)
.into_iter()
.map(|directory| directory.join("claude-desktop"))
.collect::<Vec<_>>();
candidates.extend([
PathBuf::from("/opt/Claude/claude-desktop"),
PathBuf::from("/opt/Claude/claude"),
PathBuf::from("/opt/claude-desktop/claude-desktop"),
]);
candidates.into_iter().find(|path| path.is_file())
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = home;
None
}
}
#[cfg(target_os = "windows")]
fn windows_command_for_executable(executable: &Path, keep_shell_open: bool) -> Command {
use std::os::windows::process::CommandExt;
let is_batch_script = executable
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
});
if !is_batch_script {
return Command::new(executable);
}
let mut command = Command::new(windows_command_processor());
command.args(["/D", if keep_shell_open { "/K" } else { "/C" }, "call"]);
command.raw_arg(windows_batch_executable_argument(executable));
command
}
#[cfg(target_os = "windows")]
fn windows_batch_executable_argument(executable: &Path) -> String {
format!("\"{}\"", path_to_string(executable))
}
fn read_agent_version(path: &Path) -> Option<String> {
#[cfg(target_os = "windows")]
let mut command = windows_command_for_executable(path, false);
#[cfg(not(target_os = "windows"))]
let mut command = Command::new(path);
command.arg("--version");
configure_background_command(&mut command);
let output = command.output().ok()?;
let text = if output.stdout.is_empty() {
String::from_utf8_lossy(&output.stderr)
} else {
String::from_utf8_lossy(&output.stdout)
};
text.lines()
.next()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
}
fn launch_desktop_agent(executable: &Path, label: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
let mut command = {
let application = executable
.ancestors()
.find(|path| path.extension().and_then(|value| value.to_str()) == Some("app"))
.unwrap_or(executable);
let mut command = Command::new("open");
command.arg(application);
command
};
#[cfg(target_os = "windows")]
let mut command = Command::new(executable);
#[cfg(target_os = "linux")]
let mut command = Command::new(executable);
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = (executable, label);
return Err("当前平台不支持桌面智能体".to_string());
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
configure_background_command(&mut command);
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|error| format!("启动 {label} 失败: {error}"))
}
#[cfg(target_os = "macos")]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(target_os = "macos")]
fn launch_cli_agent(
executable: &Path,
label: &str,
working_directory: &Path,
) -> Result<(), String> {
let command_line = format!(
"cd {} && exec {}",
shell_single_quote(&path_to_string(working_directory)),
shell_single_quote(&path_to_string(executable))
);
let apple_script_command = command_line.replace('\\', "\\\\").replace('"', "\\\"");
let script = format!(
"tell application \"Terminal\"\nactivate\ndo script \"{apple_script_command}\"\nend tell"
);
let output = Command::new("osascript")
.arg("-e")
.arg(script)
.output()
.map_err(|error| format!("启动 {label} 终端失败: {error}"))?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(format!(
"启动 {label} 终端失败{}",
if message.is_empty() {
String::new()
} else {
format!(": {message}")
}
))
}
#[cfg(target_os = "linux")]
fn launch_cli_agent(
executable: &Path,
label: &str,
working_directory: &Path,
) -> Result<(), String> {
let terminals: &[(&str, &[&str])] = &[
("x-terminal-emulator", &["-e"]),
("gnome-terminal", &["--"]),
("konsole", &["-e"]),
("xfce4-terminal", &["-e"]),
("mate-terminal", &["--"]),
("kitty", &["-e"]),
("alacritty", &["-e"]),
("ghostty", &["-e"]),
("xterm", &["-e"]),
];
let path = env::var_os("PATH").unwrap_or_default();
let mut last_error = None;
for (terminal, arguments) in terminals {
let available = env::split_paths(&path).any(|directory| directory.join(terminal).is_file());
if !available {
continue;
}
match Command::new(terminal)
.args(*arguments)
.arg(executable)
.current_dir(working_directory)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(_) => return Ok(()),
Err(error) => last_error = Some(error.to_string()),
}
}
Err(match last_error {
Some(error) => format!("启动 {label} 失败: {error}"),
None => format!("启动 {label} 失败:未找到可用的终端程序"),
})
}
#[cfg(target_os = "windows")]
fn launch_cli_agent(
executable: &Path,
label: &str,
working_directory: &Path,
) -> Result<(), String> {
use std::os::windows::process::CommandExt;
const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
windows_command_for_executable(executable, true)
.current_dir(working_directory)
.creation_flags(CREATE_NEW_CONSOLE)
.spawn()
.map(|_| ())
.map_err(|error| format!("启动 {label} 失败: {error}"))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn launch_cli_agent(
_executable: &Path,
label: &str,
_working_directory: &Path,
) -> Result<(), String> {
Err(format!("当前平台不支持启动 {label}"))
}
fn inspect_claude_agent_config(path: &Path, port: u16) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: serde_json::Value = serde_json::from_str(
&fs::read_to_string(path).map_err(|error| format!("读取 Claude Code 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 Claude Code 配置失败: {error}"))?;
let env = root.get("env").and_then(serde_json::Value::as_object);
let expected_base = format!("http://127.0.0.1:{port}");
let configured = env
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
== Some(expected_base.as_str())
&& env
.and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN"))
.and_then(serde_json::Value::as_str)
== Some(DEFAULT_API_KEY);
let model = env
.and_then(|env| env.get("ANTHROPIC_MODEL"))
.and_then(serde_json::Value::as_str)
.or_else(|| root.get("model").and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok((configured, model))
}
fn inspect_claude_desktop_agent_config(
paths: &[PathBuf],
port: u16,
) -> Result<(bool, Option<String>), String> {
if paths.len() != 4 || !paths.iter().any(|path| path.is_file()) {
return Ok((false, None));
}
let normal = read_agent_json_or_empty(&paths[0], "Claude Desktop 主配置")?;
let threep = read_agent_json_or_empty(&paths[1], "Claude Desktop 3P 配置")?;
let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop 网关配置")?;
let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?;
let expected_base = format!("http://127.0.0.1:{port}");
let configured = normal
.get("deploymentMode")
.and_then(serde_json::Value::as_str)
== Some("3p")
&& threep
.get("deploymentMode")
.and_then(serde_json::Value::as_str)
== Some("3p")
&& profile
.get("inferenceGatewayBaseUrl")
.and_then(serde_json::Value::as_str)
== Some(expected_base.as_str())
&& profile
.get("inferenceGatewayApiKey")
.and_then(serde_json::Value::as_str)
== Some(DEFAULT_API_KEY)
&& meta.get("appliedId").and_then(serde_json::Value::as_str)
== Some(CLAUDE_DESKTOP_PROFILE_ID);
let model = profile
.get("inferenceModels")
.and_then(serde_json::Value::as_array)
.and_then(|models| models.first())
.and_then(|model| {
model
.as_str()
.or_else(|| model.get("name").and_then(serde_json::Value::as_str))
})
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok((configured, model))
}
fn read_agent_json_or_empty(path: &Path, label: &str) -> Result<serde_json::Value, String> {
if !path.is_file() {
return Ok(serde_json::json!({}));
}
let content =
fs::read_to_string(path).map_err(|error| format!("读取 {label} 失败: {error}"))?;
if content.trim().is_empty() {
return Ok(serde_json::json!({}));
}
let value: serde_json::Value =
serde_json::from_str(&content).map_err(|error| format!("解析 {label} 失败: {error}"))?;
if !value.is_object() {
return Err(format!("{label} 根节点必须是对象"));
}
Ok(value)
}
fn inspect_codex_agent_config(path: &Path, port: u16) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: toml::Value = toml::from_str(
&fs::read_to_string(path).map_err(|error| format!("读取 Codex 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 Codex 配置失败: {error}"))?;
let expected_base = format!("http://127.0.0.1:{port}/v1");
let provider = root
.get("model_providers")
.and_then(toml::Value::as_table)
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID))
.and_then(toml::Value::as_table);
let configured = root.get("model_provider").and_then(toml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
&& provider
.and_then(|provider| provider.get("base_url"))
.and_then(toml::Value::as_str)
== Some(expected_base.as_str())
&& provider
.and_then(|provider| provider.get("experimental_bearer_token"))
.and_then(toml::Value::as_str)
== Some(DEFAULT_API_KEY)
&& provider
.and_then(|provider| provider.get("wire_api"))
.and_then(toml::Value::as_str)
== Some("responses");
let model = root
.get("model")
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok((configured, model))
}
fn inspect_opencode_agent_config(path: &Path, port: u16) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: serde_json::Value = serde_json::from_str(
&fs::read_to_string(path).map_err(|error| format!("读取 OpenCode 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 OpenCode 配置失败: {error}"))?;
let provider = root
.get("provider")
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
let expected_base = format!("http://127.0.0.1:{port}/v1");
let configured = provider
.and_then(|provider| provider.get("options"))
.and_then(|options| options.get("baseURL"))
.and_then(serde_json::Value::as_str)
== Some(expected_base.as_str())
&& provider
.and_then(|provider| provider.get("options"))
.and_then(|options| options.get("apiKey"))
.and_then(serde_json::Value::as_str)
== Some(DEFAULT_API_KEY);
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let model = root
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.strip_prefix(&prefix).unwrap_or(value))
.map(str::to_string);
Ok((configured, model))
}
fn inspect_openclaw_agent_config(path: &Path, port: u16) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let content =
fs::read_to_string(path).map_err(|error| format!("读取 OpenClaw 配置失败: {error}"))?;
let root: serde_json::Value = json5::from_str(&content)
.map_err(|error| format!("解析 OpenClaw JSON5 配置失败: {error}"))?;
let provider = root
.get("models")
.and_then(|models| models.get("providers"))
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
let expected_base = format!("http://127.0.0.1:{port}/v1");
let configured = provider
.and_then(|provider| provider.get("baseUrl"))
.and_then(serde_json::Value::as_str)
== Some(expected_base.as_str())
&& provider
.and_then(|provider| provider.get("apiKey"))
.and_then(serde_json::Value::as_str)
== Some(DEFAULT_API_KEY);
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
let model = root
.get("agents")
.and_then(|agents| agents.get("defaults"))
.and_then(|defaults| defaults.get("model"))
.and_then(|model| model.get("primary"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.strip_prefix(&prefix).unwrap_or(value))
.map(str::to_string);
Ok((configured, model))
}
fn inspect_hermes_agent_config(path: &Path, port: u16) -> Result<(bool, Option<String>), String> {
if !path.is_file() {
return Ok((false, None));
}
let root: serde_yaml::Value = serde_yaml::from_str(
&fs::read_to_string(path).map_err(|error| format!("读取 Hermes 配置失败: {error}"))?,
)
.map_err(|error| format!("解析 Hermes YAML 配置失败: {error}"))?;
let provider = root
.get("custom_providers")
.and_then(serde_yaml::Value::as_sequence)
.and_then(|providers| {
providers.iter().find(|provider| {
provider.get("name").and_then(serde_yaml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID)
})
});
let expected_base = format!("http://127.0.0.1:{port}/v1");
let configured = provider
.and_then(|provider| provider.get("base_url"))
.and_then(serde_yaml::Value::as_str)
== Some(expected_base.as_str())
&& provider
.and_then(|provider| provider.get("api_key"))
.and_then(serde_yaml::Value::as_str)
== Some(DEFAULT_API_KEY)
&& root
.get("model")
.and_then(|model| model.get("provider"))
.and_then(serde_yaml::Value::as_str)
== Some(MANAGED_AGENT_PROVIDER_ID);
let model = root
.get("model")
.and_then(|model| model.get("default"))
.and_then(serde_yaml::Value::as_str)
.or_else(|| provider.and_then(|provider| provider.get("model")?.as_str()))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok((configured, model))
}
fn build_agent_updates(
client: AgentClient,
home: &Path,
port: u16,
model: &str,
models: &[AgentModelOption],
codex_models: Option<&[CodexModelCatalogSpec]>,
) -> Result<Vec<AgentFileUpdate>, String> {
let paths = agent_config_paths(client, home);
let root_base = format!("http://127.0.0.1:{port}");
let openai_base = format!("{root_base}/v1");
match client {
AgentClient::ClaudeCode => {
let before = read_optional_text(&paths[0])?;
let after =
build_claude_agent_config(before.as_deref(), &root_base, DEFAULT_API_KEY, model)?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
}])
}
AgentClient::ClaudeDesktop => {
if paths.len() != 4 {
return Err("Claude Desktop 当前平台配置路径不可用".to_string());
}
let normal_before = read_optional_text(&paths[0])?;
let threep_before = read_optional_text(&paths[1])?;
let profile_before = read_optional_text(&paths[2])?;
let meta_before = read_optional_text(&paths[3])?;
Ok(vec![
AgentFileUpdate {
path: paths[0].clone(),
after: build_claude_desktop_deployment_config(normal_before.as_deref())?,
},
AgentFileUpdate {
path: paths[1].clone(),
after: build_claude_desktop_deployment_config(threep_before.as_deref())?,
},
AgentFileUpdate {
path: paths[2].clone(),
after: build_claude_desktop_profile(
profile_before.as_deref(),
&root_base,
DEFAULT_API_KEY,
model,
models,
)?,
},
AgentFileUpdate {
path: paths[3].clone(),
after: build_claude_desktop_meta(meta_before.as_deref())?,
},
])
}
AgentClient::Codex => {
let before = read_optional_text(&paths[0])?;
let after =
build_codex_agent_config(before.as_deref(), &openai_base, DEFAULT_API_KEY, model)?;
let mut updates = vec![AgentFileUpdate {
path: paths[0].clone(),
after,
}];
if let Some(models) = codex_models {
updates.push(AgentFileUpdate {
path: codex_model_catalog_path(home),
after: build_codex_model_catalog(models)?,
});
}
Ok(updates)
}
AgentClient::OpenCode => {
let before = read_optional_text(&paths[0])?;
let after = build_opencode_agent_config(
before.as_deref(),
&openai_base,
DEFAULT_API_KEY,
model,
models,
)?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
}])
}
AgentClient::OpenClaw => {
let before = read_optional_text(&paths[0])?;
let after = build_openclaw_agent_config(
before.as_deref(),
&openai_base,
DEFAULT_API_KEY,
model,
models,
)?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
}])
}
AgentClient::Hermes => {
let before = read_optional_text(&paths[0])?;
let after = build_hermes_agent_config(
before.as_deref(),
&openai_base,
DEFAULT_API_KEY,
model,
models,
)?;
Ok(vec![AgentFileUpdate {
path: paths[0].clone(),
after,
}])
}
}
}
fn read_optional_text(path: &Path) -> Result<Option<String>, String> {
if !path.is_file() {
return Ok(None);
}
fs::read_to_string(path)
.map(Some)
.map_err(|error| format!("读取配置失败 {}: {error}", path_to_string(path)))
}
fn build_claude_agent_config(
existing: Option<&str>,
base_url: &str,
api_key: &str,
model: &str,
) -> Result<String, String> {
let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => serde_json::from_str::<serde_json::Value>(value)
.map_err(|error| format!("Claude Code settings.json 格式无效: {error}"))?,
None => serde_json::json!({}),
};
let root = root
.as_object_mut()
.ok_or_else(|| "Claude Code settings.json 根节点必须是对象".to_string())?;
let env = root
.entry("env".to_string())
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| "Claude Code env 必须是对象".to_string())?;
for (key, value) in [
("ANTHROPIC_BASE_URL", base_url),
("ANTHROPIC_API_KEY", api_key),
("ANTHROPIC_AUTH_TOKEN", api_key),
("ANTHROPIC_MODEL", model),
("ANTHROPIC_DEFAULT_HAIKU_MODEL", model),
("ANTHROPIC_DEFAULT_SONNET_MODEL", model),
("ANTHROPIC_DEFAULT_OPUS_MODEL", model),
("ANTHROPIC_DEFAULT_FABLE_MODEL", model),
] {
env.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
root.insert(
"model".to_string(),
serde_json::Value::String(model.to_string()),
);
let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone()))
.map_err(|error| format!("生成 Claude Code 配置失败: {error}"))?;
rendered.push('\n');
Ok(rendered)
}
fn parse_agent_json_object(
existing: Option<&str>,
label: &str,
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
let value = match existing.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => serde_json::from_str::<serde_json::Value>(value)
.map_err(|error| format!("{label} 格式无效: {error}"))?,
None => serde_json::json!({}),
};
value
.as_object()
.cloned()
.ok_or_else(|| format!("{label} 根节点必须是对象"))
}
fn render_agent_json(
root: serde_json::Map<String, serde_json::Value>,
label: &str,
) -> Result<String, String> {
let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root))
.map_err(|error| format!("生成 {label} 失败: {error}"))?;
rendered.push('\n');
Ok(rendered)
}
fn ordered_agent_models(
models: &[AgentModelOption],
selected_model: &str,
) -> Vec<AgentModelOption> {
let mut ordered = Vec::with_capacity(models.len().max(1));
if let Some(selected) = models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(selected_model))
{
ordered.push(selected.clone());
} else {
ordered.push(AgentModelOption {
name: selected_model.to_string(),
alias: None,
});
}
for model in models {
if ordered
.iter()
.any(|existing| existing.name.eq_ignore_ascii_case(&model.name))
{
continue;
}
ordered.push(model.clone());
}
ordered
}
fn build_claude_desktop_deployment_config(existing: Option<&str>) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "Claude Desktop 配置")?;
root.insert("deploymentMode".to_string(), serde_json::json!("3p"));
render_agent_json(root, "Claude Desktop 配置")
}
fn build_claude_desktop_profile(
existing: Option<&str>,
base_url: &str,
api_key: &str,
model: &str,
available_models: &[AgentModelOption],
) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?;
root.insert(
"coworkEgressAllowedHosts".to_string(),
serde_json::json!(["*"]),
);
root.insert(
"disableDeploymentModeChooser".to_string(),
serde_json::json!(true),
);
root.insert(
"inferenceGatewayApiKey".to_string(),
serde_json::json!(api_key),
);
root.insert(
"inferenceGatewayAuthScheme".to_string(),
serde_json::json!("bearer"),
);
root.insert(
"inferenceGatewayBaseUrl".to_string(),
serde_json::json!(base_url),
);
root.insert(
"inferenceProvider".to_string(),
serde_json::json!("gateway"),
);
let inference_models = ordered_agent_models(available_models, model)
.into_iter()
.map(|model| match model.alias {
Some(alias) => serde_json::json!({
"name": model.name,
"labelOverride": alias,
}),
None => serde_json::json!(model.name),
})
.collect::<Vec<_>>();
root.insert(
"inferenceModels".to_string(),
serde_json::Value::Array(inference_models),
);
render_agent_json(root, "Claude Desktop 网关配置")
}
fn build_claude_desktop_meta(existing: Option<&str>) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "Claude Desktop 配置索引")?;
let entries = root
.entry("entries".to_string())
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| "Claude Desktop 配置索引 entries 必须是数组".to_string())?;
entries.retain(|entry| {
entry.get("id").and_then(serde_json::Value::as_str) != Some(CLAUDE_DESKTOP_PROFILE_ID)
});
entries.push(serde_json::json!({
"id": CLAUDE_DESKTOP_PROFILE_ID,
"name": "EasyCLIProxyAPI"
}));
root.insert(
"appliedId".to_string(),
serde_json::json!(CLAUDE_DESKTOP_PROFILE_ID),
);
render_agent_json(root, "Claude Desktop 配置索引")
}
fn build_codex_agent_config(
existing: Option<&str>,
base_url: &str,
api_key: &str,
model: &str,
) -> Result<String, String> {
use toml_edit::{value, Document, Item, Table};
let mut document = match existing.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => value
.parse::<Document>()
.map_err(|error| format!("Codex config.toml 格式无效: {error}"))?,
None => Document::new(),
};
document["model_provider"] = value(MANAGED_AGENT_PROVIDER_ID);
document["model"] = value(model);
document["model_catalog_json"] = value(CODEX_MODEL_CATALOG_FILE);
if !document.contains_key("model_providers") {
document["model_providers"] = Item::Table(Table::new());
}
let providers = document["model_providers"]
.as_table_mut()
.ok_or_else(|| "Codex model_providers 必须是 TOML 表".to_string())?;
let mut provider = providers
.get(MANAGED_AGENT_PROVIDER_ID)
.and_then(Item::as_table)
.cloned()
.unwrap_or_default();
provider["name"] = value("EasyCLIProxyAPI");
provider["base_url"] = value(base_url);
provider["wire_api"] = value("responses");
provider["experimental_bearer_token"] = value(api_key);
providers.insert(MANAGED_AGENT_PROVIDER_ID, Item::Table(provider));
let rendered = document.to_string();
toml::from_str::<toml::Value>(&rendered)
.map_err(|error| format!("验证 Codex 配置失败: {error}"))?;
Ok(rendered)
}
fn build_codex_model_catalog(models: &[CodexModelCatalogSpec]) -> Result<String, String> {
if models.is_empty() {
return Err("当前 CPA 没有可写入 Codex 的模型".to_string());
}
let entries = models
.iter()
.enumerate()
.map(|(index, model)| {
let reasoning_levels = if model.reasoning_levels.is_empty() {
default_codex_reasoning_levels()
} else {
model.reasoning_levels.clone()
};
let default_reasoning_level = if reasoning_levels.iter().any(|level| level == "medium") {
"medium".to_string()
} else {
reasoning_levels
.first()
.cloned()
.unwrap_or_else(|| "medium".to_string())
};
let supported_reasoning_levels = reasoning_levels
.iter()
.map(|level| {
serde_json::json!({
"effort": level,
"description": codex_reasoning_level_description(level),
})
})
.collect::<Vec<_>>();
serde_json::json!({
"slug": model.id,
"display_name": model.display_name,
"description": model.description,
"base_instructions": "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.",
"default_reasoning_level": default_reasoning_level,
"supported_reasoning_levels": supported_reasoning_levels,
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": index + 1,
"include_skills_usage_instructions": true,
"supports_reasoning_summaries": true,
"default_reasoning_summary": "none",
"support_verbosity": false,
"truncation_policy": {
"mode": "bytes",
"limit": 10_000,
},
"supports_parallel_tool_calls": model.supports_parallel_tool_calls,
"supports_image_detail_original": false,
"context_window": model.context_window,
"max_context_window": model.context_window,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": ["text", "image"],
"supports_search_tool": false,
})
})
.collect::<Vec<_>>();
let mut rendered = serde_json::to_string_pretty(&serde_json::json!({ "models": entries }))
.map_err(|error| format!("生成 Codex 模型目录失败: {error}"))?;
rendered.push('\n');
serde_json::from_str::<serde_json::Value>(&rendered)
.map_err(|error| format!("验证 Codex 模型目录失败: {error}"))?;
Ok(rendered)
}
fn codex_reasoning_level_description(level: &str) -> &'static str {
match level {
"none" => "关闭推理",
"minimal" => "最少推理",
"low" => "较低推理强度",
"medium" => "平衡速度与推理深度",
"high" => "较高推理强度",
"xhigh" => "超高推理强度",
"max" => "最大推理强度",
"ultra" => "最高推理与自动任务委派",
_ => "模型支持的推理强度",
}
}
fn build_opencode_agent_config(
existing: Option<&str>,
base_url: &str,
api_key: &str,
model: &str,
available_models: &[AgentModelOption],
) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "OpenCode opencode.json")?;
root.entry("$schema".to_string())
.or_insert_with(|| serde_json::json!("https://opencode.ai/config.json"));
let providers = root
.entry("provider".to_string())
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| "OpenCode provider 必须是对象".to_string())?;
let models = ordered_agent_models(available_models, model)
.into_iter()
.map(|model| {
let display_name = model.alias.as_deref().unwrap_or(&model.name).to_string();
(model.name, serde_json::json!({ "name": display_name }))
})
.collect::<serde_json::Map<_, _>>();
providers.insert(
MANAGED_AGENT_PROVIDER_ID.to_string(),
serde_json::json!({
"npm": "@ai-sdk/openai-compatible",
"name": "EasyCLIProxyAPI",
"options": {
"baseURL": base_url,
"apiKey": api_key
},
"models": models
}),
);
root.insert(
"model".to_string(),
serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")),
);
render_agent_json(root, "OpenCode 配置")
}
fn build_openclaw_agent_config(
existing: Option<&str>,
base_url: &str,
api_key: &str,
model: &str,
available_models: &[AgentModelOption],
) -> Result<String, String> {
let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => json5::from_str::<serde_json::Value>(value)
.map_err(|error| format!("OpenClaw openclaw.json 格式无效: {error}"))?,
None => serde_json::json!({}),
};
let root = root
.as_object_mut()
.ok_or_else(|| "OpenClaw openclaw.json 根节点必须是对象".to_string())?;
let ordered_models = ordered_agent_models(available_models, model);
let models = root
.entry("models".to_string())
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| "OpenClaw models 必须是对象".to_string())?;
models
.entry("mode".to_string())
.or_insert_with(|| serde_json::json!("merge"));
let providers = models
.entry("providers".to_string())
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| "OpenClaw models.providers 必须是对象".to_string())?;
providers.insert(
MANAGED_AGENT_PROVIDER_ID.to_string(),
serde_json::json!({
"baseUrl": base_url,
"apiKey": api_key,
"api": "openai-completions",
"models": ordered_models.iter().map(|model| serde_json::json!({
"id": model.name.clone(),
"name": model.alias.as_deref().unwrap_or(&model.name),
})).collect::<Vec<_>>()
}),
);
let agents = root
.entry("agents".to_string())
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| "OpenClaw agents 必须是对象".to_string())?;
let defaults = agents
.entry("defaults".to_string())
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| "OpenClaw agents.defaults 必须是对象".to_string())?;
let default_model = defaults
.entry("model".to_string())
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| "OpenClaw agents.defaults.model 必须是对象".to_string())?;
default_model.insert(
"primary".to_string(),
serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")),
);
let model_catalog = defaults
.entry("models".to_string())
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| "OpenClaw agents.defaults.models 必须是对象".to_string())?;
let managed_prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
model_catalog.retain(|name, _| !name.starts_with(&managed_prefix));
for model in &ordered_models {
let name = format!("{MANAGED_AGENT_PROVIDER_ID}/{}", model.name);
let value = model
.alias
.as_deref()
.map(|alias| serde_json::json!({ "alias": alias }))
.unwrap_or_else(|| serde_json::json!({}));
model_catalog.insert(name, value);
}
render_agent_json(root.clone(), "OpenClaw 配置")
}
fn build_hermes_agent_config(
existing: Option<&str>,
base_url: &str,
api_key: &str,
model: &str,
available_models: &[AgentModelOption],
) -> Result<String, String> {
let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => serde_yaml::from_str::<serde_yaml::Value>(value)
.map_err(|error| format!("Hermes config.yaml 格式无效: {error}"))?,
None => serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
};
let mapping = root
.as_mapping_mut()
.ok_or_else(|| "Hermes config.yaml 根节点必须是映射".to_string())?;
let providers = mapping
.entry(serde_yaml::Value::String("custom_providers".to_string()))
.or_insert_with(|| serde_yaml::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| "Hermes custom_providers 必须是数组".to_string())?;
providers.retain(|provider| {
provider.get("name").and_then(serde_yaml::Value::as_str) != Some(MANAGED_AGENT_PROVIDER_ID)
});
let provider_models = ordered_agent_models(available_models, model)
.into_iter()
.map(|model| (model.name, serde_json::json!({})))
.collect::<serde_json::Map<_, _>>();
providers.push(
serde_yaml::to_value(serde_json::json!({
"name": MANAGED_AGENT_PROVIDER_ID,
"base_url": base_url,
"api_key": api_key,
"api_mode": "chat_completions",
"model": model,
"models": provider_models
}))
.map_err(|error| format!("生成 Hermes provider 失败: {error}"))?,
);
let model_config = mapping
.entry(serde_yaml::Value::String("model".to_string()))
.or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "Hermes model 必须是映射".to_string())?;
model_config.insert(
serde_yaml::Value::String("default".to_string()),
serde_yaml::Value::String(model.to_string()),
);
model_config.insert(
serde_yaml::Value::String("provider".to_string()),
serde_yaml::Value::String(MANAGED_AGENT_PROVIDER_ID.to_string()),
);
let rendered =
serde_yaml::to_string(&root).map_err(|error| format!("生成 Hermes 配置失败: {error}"))?;
serde_yaml::from_str::<serde_yaml::Value>(&rendered)
.map_err(|error| format!("验证 Hermes 配置失败: {error}"))?;
Ok(rendered)
}
fn agent_backup_path(path: &Path) -> Result<PathBuf, String> {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("智能体配置文件名无效: {}", path_to_string(path)))?;
Ok(path.with_file_name(format!("{file_name}.cpa-gui.backup")))
}
fn agent_state_path(paths: &[PathBuf]) -> Result<PathBuf, String> {
let primary = paths
.first()
.ok_or_else(|| "当前平台没有可用的智能体配置路径".to_string())?;
let file_name = primary
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("智能体配置文件名无效: {}", path_to_string(primary)))?;
Ok(primary.with_file_name(format!("{file_name}.cpa-gui.state.json")))
}
fn sha256_bytes(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn read_agent_bytes(path: &Path) -> Result<Option<Vec<u8>>, String> {
if !path.is_file() {
return Ok(None);
}
fs::read(path)
.map(Some)
.map_err(|error| format!("读取智能体配置失败 {}: {error}", path_to_string(path)))
}
fn write_agent_state(path: &Path, record: &AgentModificationRecord) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!("创建智能体状态目录失败 {}: {error}", path_to_string(parent))
})?;
}
let mut content = serde_json::to_string_pretty(record)
.map_err(|error| format!("生成智能体备份状态失败: {error}"))?;
content.push('\n');
write_yaml_if_changed(path, &content).map(|_| ())
}
fn validate_agent_record(
client: AgentClient,
paths: &[PathBuf],
record: &AgentModificationRecord,
) -> Result<(), String> {
if record.version != AGENT_MODIFICATION_STATE_VERSION || record.client != client.id() {
return Err("智能体备份状态版本或客户端不匹配".to_string());
}
if ![
AGENT_PHASE_APPLYING,
AGENT_PHASE_ACTIVE,
AGENT_PHASE_RESTORING,
AGENT_PHASE_RECOVERY,
]
.contains(&record.phase.as_str())
{
return Err("智能体备份状态阶段无效".to_string());
}
let expected_paths = expected_agent_record_paths(client, paths);
let record_paths = record
.files
.iter()
.map(|file| file.path.clone())
.collect::<Vec<_>>();
let valid_paths = record_paths.as_slice() == paths
|| (client == AgentClient::Codex && record_paths == expected_paths);
if !valid_paths {
return Err("智能体备份状态文件数量或路径不匹配".to_string());
}
for file in &record.files {
if file.backup_path != agent_backup_path(&file.path)? {
return Err("智能体备份状态包含非预期路径".to_string());
}
}
Ok(())
}
fn load_agent_record(
client: AgentClient,
paths: &[PathBuf],
) -> Result<Option<AgentModificationRecord>, String> {
let state_path = agent_state_path(paths)?;
if !state_path.is_file() {
return Ok(None);
}
let record: AgentModificationRecord = serde_json::from_str(
&fs::read_to_string(&state_path)
.map_err(|error| format!("读取智能体备份状态失败: {error}"))?,
)
.map_err(|error| format!("解析智能体备份状态失败: {error}"))?;
validate_agent_record(client, paths, &record)?;
Ok(Some(record))
}
fn record_backup_available(record: &AgentModificationRecord) -> bool {
record
.files
.iter()
.all(|file| !file.existed_before || file.backup_path.is_file())
}
fn record_conflict_files(record: &AgentModificationRecord) -> Result<Vec<String>, String> {
let mut conflicts = Vec::new();
for file in &record.files {
let current = read_agent_bytes(&file.path)?;
let matches =
current.as_deref().map(sha256_bytes).as_deref() == Some(file.managed_sha256.as_str());
if !matches {
conflicts.push(path_to_string(&file.path));
}
}
Ok(conflicts)
}
fn record_restore_conflict_files(record: &AgentModificationRecord) -> Result<Vec<String>, String> {
let mut conflicts = Vec::new();
for file in &record.files {
let current = read_agent_bytes(&file.path)?;
let matches_managed =
current.as_deref().map(sha256_bytes).as_deref() == Some(file.managed_sha256.as_str());
let matches_original = if file.existed_before {
current.as_deref().map(sha256_bytes) == file.original_sha256
} else {
current.is_none()
};
if !matches_managed && !matches_original {
conflicts.push(path_to_string(&file.path));
}
}
Ok(conflicts)
}
fn record_matches_original(record: &AgentModificationRecord) -> Result<bool, String> {
for file in &record.files {
let current = read_agent_bytes(&file.path)?;
let matches = if file.existed_before {
current.as_deref().map(sha256_bytes) == file.original_sha256
} else {
current.is_none()
};
if !matches {
return Ok(false);
}
}
Ok(true)
}
fn inspect_agent_modification(
client: AgentClient,
home: &Path,
port: u16,
configured: bool,
current_model: Option<&str>,
) -> AgentModificationInspection {
let paths = agent_config_paths(client, home);
let state_path = match agent_state_path(&paths) {
Ok(path) => path,
Err(_) => {
return AgentModificationInspection {
enabled: false,
state: "inactive".to_string(),
backup_available: false,
applied_model: current_model.map(str::to_string),
warnings: Vec::new(),
}
}
};
if state_path.is_file() {
return match load_agent_record(client, &paths) {
Ok(Some(record)) => {
let backup_available = record_backup_available(&record);
let conflicts = record_conflict_files(&record);
let state = if record.phase != AGENT_PHASE_ACTIVE {
"recovery"
} else {
match conflicts {
Ok(conflicts) if conflicts.is_empty() => "active",
Ok(_) => AGENT_MODIFICATION_STATE_CONFLICT,
Err(_) => "recovery",
}
};
let mut warnings = Vec::new();
if !backup_available {
warnings.push("原配置备份不完整,恢复前请勿删除剩余备份文件".to_string());
}
if state == AGENT_MODIFICATION_STATE_CONFLICT {
warnings.push("配置已被其他程序修改,关闭接管时需要确认强制恢复".to_string());
} else if state == "recovery" {
warnings.push("上次配置操作未完整结束,可关闭开关恢复原配置".to_string());
}
AgentModificationInspection {
enabled: true,
state: state.to_string(),
backup_available,
applied_model: Some(record.model),
warnings,
}
}
Ok(None) => AgentModificationInspection {
enabled: false,
state: "inactive".to_string(),
backup_available: false,
applied_model: current_model.map(str::to_string),
warnings: Vec::new(),
},
Err(error) => AgentModificationInspection {
enabled: true,
state: "recovery".to_string(),
backup_available: expected_agent_record_paths(client, &paths)
.iter()
.filter_map(|path| agent_backup_path(path).ok())
.any(|path| path.is_file()),
applied_model: current_model.map(str::to_string),
warnings: vec![error],
},
};
}
if configured {
if let Some(model) = current_model {
match build_legacy_agent_record(client, home, port, model) {
Ok(Some(record)) => {
return AgentModificationInspection {
enabled: true,
state: "active".to_string(),
backup_available: true,
applied_model: Some(record.model),
warnings: vec![
"检测到旧版 CPA 配置和备份,可关闭开关恢复原配置".to_string()
],
}
}
Ok(None) => {
return AgentModificationInspection {
enabled: false,
state: "inactive".to_string(),
backup_available: false,
applied_model: current_model.map(str::to_string),
warnings: vec!["检测到 CPA 配置,但缺少可安全恢复的原始备份".to_string()],
}
}
Err(error) => {
return AgentModificationInspection {
enabled: false,
state: "inactive".to_string(),
backup_available: false,
applied_model: current_model.map(str::to_string),
warnings: vec![error],
}
}
}
}
}
AgentModificationInspection {
enabled: false,
state: "inactive".to_string(),
backup_available: false,
applied_model: current_model.map(str::to_string),
warnings: Vec::new(),
}
}
fn fresh_agent_contents(
client: AgentClient,
port: u16,
model: &str,
) -> Result<Vec<String>, String> {
let root_base = format!("http://127.0.0.1:{port}");
let openai_base = format!("{root_base}/v1");
let models = [AgentModelOption {
name: model.to_string(),
alias: None,
}];
match client {
AgentClient::ClaudeCode => Ok(vec![build_claude_agent_config(
None,
&root_base,
DEFAULT_API_KEY,
model,
)?]),
AgentClient::ClaudeDesktop => Ok(vec![
build_claude_desktop_deployment_config(None)?,
build_claude_desktop_deployment_config(None)?,
build_claude_desktop_profile(None, &root_base, DEFAULT_API_KEY, model, &models)?,
build_claude_desktop_meta(None)?,
]),
AgentClient::Codex => Ok(vec![build_codex_agent_config(
None,
&openai_base,
DEFAULT_API_KEY,
model,
)?]),
AgentClient::OpenCode => Ok(vec![build_opencode_agent_config(
None,
&openai_base,
DEFAULT_API_KEY,
model,
&models,
)?]),
AgentClient::OpenClaw => Ok(vec![build_openclaw_agent_config(
None,
&openai_base,
DEFAULT_API_KEY,
model,
&models,
)?]),
AgentClient::Hermes => Ok(vec![build_hermes_agent_config(
None,
&openai_base,
DEFAULT_API_KEY,
model,
&models,
)?]),
}
}
fn agent_contents_equal(client: AgentClient, actual: &str, expected: &str) -> bool {
match client {
AgentClient::Codex => {
normalize_codex_config_for_legacy_compare(actual)
== normalize_codex_config_for_legacy_compare(expected)
}
AgentClient::OpenClaw => {
json5::from_str::<serde_json::Value>(actual).ok()
== json5::from_str::<serde_json::Value>(expected).ok()
}
AgentClient::Hermes => {
serde_yaml::from_str::<serde_yaml::Value>(actual).ok()
== serde_yaml::from_str::<serde_yaml::Value>(expected).ok()
}
_ => {
serde_json::from_str::<serde_json::Value>(actual).ok()
== serde_json::from_str::<serde_json::Value>(expected).ok()
}
}
}
fn normalize_codex_config_for_legacy_compare(content: &str) -> Option<toml::Value> {
let mut value = toml::from_str::<toml::Value>(content).ok()?;
if value
.get("model_catalog_json")
.and_then(toml::Value::as_str)
== Some(CODEX_MODEL_CATALOG_FILE)
{
value.as_table_mut()?.remove("model_catalog_json");
}
Some(value)
}
fn build_legacy_agent_record(
client: AgentClient,
home: &Path,
port: u16,
model: &str,
) -> Result<Option<AgentModificationRecord>, String> {
let paths = agent_config_paths(client, home);
let generated = fresh_agent_contents(client, port, model)?;
if generated.len() != paths.len() {
return Ok(None);
}
let mut files = Vec::new();
for (index, path) in paths.iter().enumerate() {
let current = read_agent_bytes(path)?;
let Some(current) = current else {
return Ok(None);
};
let backup_path = agent_backup_path(path)?;
let (existed_before, original_sha256) = if backup_path.is_file() {
let backup = fs::read(&backup_path).map_err(|error| {
format!(
"读取旧版智能体备份失败 {}: {error}",
path_to_string(&backup_path)
)
})?;
(true, Some(sha256_bytes(&backup)))
} else {
let actual = String::from_utf8(current.clone())
.map_err(|_| format!("智能体配置不是 UTF-8 文本: {}", path_to_string(path)))?;
if !agent_contents_equal(client, &actual, &generated[index]) {
return Ok(None);
}
(false, None)
};
files.push(AgentModificationFile {
path: path.clone(),
backup_path,
existed_before,
original_sha256,
managed_sha256: sha256_bytes(&current),
});
}
Ok(Some(AgentModificationRecord {
version: AGENT_MODIFICATION_STATE_VERSION,
client: client.id().to_string(),
phase: AGENT_PHASE_ACTIVE.to_string(),
model: model.to_string(),
files,
}))
}
fn prepare_agent_record(
client: AgentClient,
paths: &[PathBuf],
model: &str,
updates: &[AgentFileUpdate],
) -> Result<AgentModificationRecord, String> {
if paths.len() != updates.len() {
return Err("智能体配置更新文件数量不匹配".to_string());
}
let mut prepared = Vec::new();
for (path, update) in paths.iter().zip(updates) {
if path != &update.path {
return Err("智能体配置更新路径不匹配".to_string());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!("创建智能体配置目录失败 {}: {error}", path_to_string(parent))
})?;
}
let backup_path = agent_backup_path(path)?;
let current = read_agent_bytes(path)?;
let previous_backup = if backup_path.exists() {
if !backup_path.is_file() {
return Err(format!(
"智能体备份路径不是文件: {}",
path_to_string(&backup_path)
));
}
Some(fs::read(&backup_path).map_err(|error| {
format!(
"读取原有智能体备份失败 {}: {error}",
path_to_string(&backup_path)
)
})?)
} else {
None
};
prepared.push((
path.clone(),
backup_path,
current,
previous_backup,
sha256_bytes(update.after.as_bytes()),
));
}
let backup_snapshots = prepared
.iter()
.map(|(_, backup_path, _, previous_backup, _)| {
(backup_path.clone(), previous_backup.clone())
})
.collect::<Vec<_>>();
let mut files = Vec::new();
for (path, backup_path, current, _, managed_sha256) in prepared {
let backup_result = if let Some(current) = current.as_deref() {
write_bytes_atomically(&backup_path, current).and_then(|_| {
let copied = fs::read(&backup_path).map_err(|error| {
format!(
"校验智能体备份失败 {}: {error}",
path_to_string(&backup_path)
)
})?;
if sha256_bytes(&copied) != sha256_bytes(current) {
return Err(format!("智能体备份校验失败: {}", path_to_string(&path)));
}
Ok(())
})
} else if backup_path.exists() {
fs::remove_file(&backup_path).map_err(|error| {
format!(
"清理旧智能体备份失败 {}: {error}",
path_to_string(&backup_path)
)
})
} else {
Ok(())
};
if let Err(error) = backup_result {
let rollback = restore_snapshots(&backup_snapshots);
return Err(match rollback {
Ok(()) => error,
Err(rollback_error) => format!("{error};恢复原有备份失败: {rollback_error}"),
});
}
let existed_before = current.is_some();
let original_sha256 = current.as_deref().map(sha256_bytes);
files.push(AgentModificationFile {
path,
backup_path,
existed_before,
original_sha256,
managed_sha256,
});
}
Ok(AgentModificationRecord {
version: AGENT_MODIFICATION_STATE_VERSION,
client: client.id().to_string(),
phase: AGENT_PHASE_APPLYING.to_string(),
model: model.to_string(),
files,
})
}
fn extend_agent_record_for_updates(
record: &AgentModificationRecord,
updates: &[AgentFileUpdate],
) -> Result<AgentRecordExtension, String> {
if record.files.len() > updates.len() {
return Err("智能体配置更新文件数量不匹配".to_string());
}
for (file, update) in record.files.iter().zip(updates) {
if file.path != update.path {
return Err("智能体配置更新路径不匹配".to_string());
}
}
if record.files.len() == updates.len() {
return Ok((record.clone(), Vec::new()));
}
let mut prepared = Vec::new();
for update in updates.iter().skip(record.files.len()) {
if let Some(parent) = update.path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!("创建智能体配置目录失败 {}: {error}", path_to_string(parent))
})?;
}
let backup_path = agent_backup_path(&update.path)?;
let previous_backup = if backup_path.exists() {
if !backup_path.is_file() {
return Err(format!(
"智能体备份路径不是文件: {}",
path_to_string(&backup_path)
));
}
Some(fs::read(&backup_path).map_err(|error| {
format!(
"读取原有智能体备份失败 {}: {error}",
path_to_string(&backup_path)
)
})?)
} else {
None
};
let current = read_agent_bytes(&update.path)?;
prepared.push((update, backup_path, previous_backup, current));
}
let backup_snapshots = prepared
.iter()
.map(|(_, backup_path, previous_backup, _)| (backup_path.clone(), previous_backup.clone()))
.collect::<Vec<_>>();
let mut next = record.clone();
for (update, backup_path, _, current) in prepared {
let backup_result = if let Some(current) = current.as_deref() {
write_bytes_atomically(&backup_path, current).and_then(|_| {
let copied = fs::read(&backup_path).map_err(|error| {
format!(
"校验智能体备份失败 {}: {error}",
path_to_string(&backup_path)
)
})?;
if sha256_bytes(&copied) != sha256_bytes(current) {
return Err(format!(
"智能体备份校验失败: {}",
path_to_string(&update.path)
));
}
Ok(())
})
} else if backup_path.exists() {
fs::remove_file(&backup_path).map_err(|error| {
format!(
"清理旧智能体备份失败 {}: {error}",
path_to_string(&backup_path)
)
})
} else {
Ok(())
};
if let Err(error) = backup_result {
let rollback = restore_snapshots(&backup_snapshots);
return Err(match rollback {
Ok(()) => error,
Err(rollback_error) => format!("{error};恢复原有备份失败: {rollback_error}"),
});
}
next.files.push(AgentModificationFile {
path: update.path.clone(),
backup_path,
existed_before: current.is_some(),
original_sha256: current.as_deref().map(sha256_bytes),
managed_sha256: sha256_bytes(update.after.as_bytes()),
});
}
Ok((next, backup_snapshots))
}
fn restore_snapshots(snapshots: &[FileSnapshot]) -> Result<(), String> {
let mut errors = Vec::new();
for (path, bytes) in snapshots.iter().rev() {
let result = match bytes {
Some(bytes) => write_bytes_atomically(path, bytes),
None if path.exists() => fs::remove_file(path)
.map_err(|error| format!("删除配置失败 {}: {error}", path_to_string(path))),
None => Ok(()),
};
if let Err(error) = result {
errors.push(error);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(";"))
}
}
fn apply_agent_updates(updates: &[AgentFileUpdate]) -> Result<Vec<String>, String> {
let snapshots = updates
.iter()
.map(|update| Ok((update.path.clone(), read_agent_bytes(&update.path)?)))
.collect::<Result<Vec<_>, String>>()?;
let mut changed = Vec::new();
for update in updates {
let next = update.after.as_bytes();
if read_agent_bytes(&update.path)?.as_deref() == Some(next) {
continue;
}
if let Err(error) = write_bytes_atomically(&update.path, next) {
let rollback = restore_snapshots(&snapshots);
return Err(match rollback {
Ok(()) => error,
Err(rollback_error) => format!("{error};回滚失败: {rollback_error}"),
});
}
changed.push(path_to_string(&update.path));
}
Ok(changed)
}
fn restore_agent_record_files(record: &AgentModificationRecord) -> Result<Vec<String>, String> {
let snapshots = record
.files
.iter()
.map(|file| Ok((file.path.clone(), read_agent_bytes(&file.path)?)))
.collect::<Result<Vec<_>, String>>()?;
let mut changed = Vec::new();
for file in &record.files {
let result = if file.existed_before {
let backup = fs::read(&file.backup_path).map_err(|error| {
format!(
"读取原配置备份失败 {}: {error}",
path_to_string(&file.backup_path)
)
})?;
if Some(sha256_bytes(&backup)) != file.original_sha256 {
return Err(format!(
"原配置备份校验失败: {}",
path_to_string(&file.backup_path)
));
}
if read_agent_bytes(&file.path)?.as_deref() == Some(backup.as_slice()) {
Ok(())
} else {
changed.push(path_to_string(&file.path));
write_bytes_atomically(&file.path, &backup)
}
} else if file.path.exists() {
changed.push(path_to_string(&file.path));
fs::remove_file(&file.path).map_err(|error| {
format!("删除智能体配置失败 {}: {error}", path_to_string(&file.path))
})
} else {
Ok(())
};
if let Err(error) = result {
let rollback = restore_snapshots(&snapshots);
return Err(match rollback {
Ok(()) => error,
Err(rollback_error) => format!("{error};回滚失败: {rollback_error}"),
});
}
}
Ok(changed)
}
fn discard_prepared_agent_backups(record: &AgentModificationRecord) -> Result<(), String> {
let mut errors = Vec::new();
for file in &record.files {
if file.backup_path.exists() {
if let Err(error) = fs::remove_file(&file.backup_path) {
errors.push(format!(
"删除未启用的智能体备份失败 {}: {error}",
path_to_string(&file.backup_path)
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(";"))
}
}
fn cleanup_agent_record(state_path: &Path, record: &AgentModificationRecord) -> Result<(), String> {
if state_path.exists() {
fs::remove_file(state_path).map_err(|error| {
format!(
"删除智能体备份状态失败 {}: {error}",
path_to_string(state_path)
)
})?;
}
for file in &record.files {
if file.backup_path.exists() {
if let Err(error) = fs::remove_file(&file.backup_path) {
eprintln!(
"清理智能体备份失败 {}: {error}",
path_to_string(&file.backup_path)
);
}
}
}
Ok(())
}
fn action_result(
outcome: &str,
enabled: bool,
model: Option<String>,
changed_files: Vec<String>,
conflict_files: Vec<String>,
) -> AgentConfigActionResult {
AgentConfigActionResult {
outcome: outcome.to_string(),
enabled,
model,
changed_files,
conflict_files,
}
}
fn enable_agent_modification(
client: AgentClient,
home: &Path,
port: u16,
model: &str,
models: &[AgentModelOption],
codex_models: Option<&[CodexModelCatalogSpec]>,
) -> Result<AgentConfigActionResult, String> {
let paths = agent_config_paths(client, home);
let state_path = agent_state_path(&paths)?;
if let Some(record) = load_agent_record(client, &paths)? {
return Ok(action_result(
"enabled",
true,
Some(record.model),
Vec::new(),
Vec::new(),
));
}
let (_, current_model) = inspect_agent_managed_config(client, &paths, port)?;
if agent_has_managed_marker(client, &paths)? {
if let Some(current_model) = current_model.as_deref() {
if let Some(record) = build_legacy_agent_record(client, home, port, current_model)? {
write_agent_state(&state_path, &record)?;
return Ok(action_result(
"enabled",
true,
Some(record.model),
Vec::new(),
Vec::new(),
));
}
}
return Err(
"检测到 CPA 配置,但缺少可安全恢复的原始备份;请先手动恢复客户端配置".to_string(),
);
}
let updates = build_agent_updates(client, home, port, model, models, codex_models)?;
let update_paths = updates
.iter()
.map(|update| update.path.clone())
.collect::<Vec<_>>();
let mut record = prepare_agent_record(client, &update_paths, model, &updates)?;
if let Err(error) = write_agent_state(&state_path, &record) {
let cleanup = discard_prepared_agent_backups(&record);
return Err(match cleanup {
Ok(()) => error,
Err(cleanup_error) => format!("{error};{cleanup_error}"),
});
}
match apply_agent_updates(&updates) {
Ok(changed) => {
record.phase = AGENT_PHASE_ACTIVE.to_string();
write_agent_state(&state_path, &record)?;
Ok(action_result(
"enabled",
true,
Some(model.to_string()),
changed,
Vec::new(),
))
}
Err(error) => match restore_agent_record_files(&record) {
Ok(_) => {
let _ = cleanup_agent_record(&state_path, &record);
Err(error)
}
Err(restore_error) => {
record.phase = AGENT_PHASE_RECOVERY.to_string();
let _ = write_agent_state(&state_path, &record);
Err(format!("{error};恢复原配置失败: {restore_error}"))
}
},
}
}
fn disable_agent_modification(
client: AgentClient,
home: &Path,
port: u16,
force_restore: bool,
) -> Result<AgentConfigActionResult, String> {
let paths = agent_config_paths(client, home);
let state_path = agent_state_path(&paths)?;
let mut record = match load_agent_record(client, &paths)? {
Some(record) => record,
None => {
let (_, model) = inspect_agent_managed_config(client, &paths, port)?;
if !agent_has_managed_marker(client, &paths)? {
return Ok(action_result(
"disabled",
false,
None,
Vec::new(),
Vec::new(),
));
}
let model = model.ok_or_else(|| "无法识别当前 CPA 模型".to_string())?;
let record = build_legacy_agent_record(client, home, port, &model)?
.ok_or_else(|| "检测到 CPA 配置,但缺少可安全恢复的原始备份".to_string())?;
write_agent_state(&state_path, &record)?;
record
}
};
if record_matches_original(&record)? {
cleanup_agent_record(&state_path, &record)?;
return Ok(action_result(
"disabled",
false,
None,
Vec::new(),
Vec::new(),
));
}
let conflicts = record_restore_conflict_files(&record)?;
if !conflicts.is_empty() && !force_restore {
return Ok(action_result(
"restore-conflict",
true,
Some(record.model),
Vec::new(),
conflicts,
));
}
record.phase = AGENT_PHASE_RESTORING.to_string();
write_agent_state(&state_path, &record)?;
match restore_agent_record_files(&record) {
Ok(changed) => {
cleanup_agent_record(&state_path, &record)?;
Ok(action_result("disabled", false, None, changed, Vec::new()))
}
Err(error) => {
record.phase = AGENT_PHASE_RECOVERY.to_string();
let _ = write_agent_state(&state_path, &record);
Err(format!("恢复原配置失败: {error}"))
}
}
}
fn update_agent_modification(
client: AgentClient,
home: &Path,
port: u16,
model: &str,
models: &[AgentModelOption],
codex_models: Option<&[CodexModelCatalogSpec]>,
) -> Result<AgentConfigActionResult, String> {
let paths = agent_config_paths(client, home);
let state_path = agent_state_path(&paths)?;
let record = match load_agent_record(client, &paths)? {
Some(record) => record,
None => {
let (_, current_model) = inspect_agent_managed_config(client, &paths, port)?;
if !agent_has_managed_marker(client, &paths)? {
return Err("请先开启修改配置".to_string());
}
let current_model = current_model.ok_or_else(|| "无法识别当前 CPA 模型".to_string())?;
let record = build_legacy_agent_record(client, home, port, &current_model)?
.ok_or_else(|| "缺少原配置备份,无法安全更新".to_string())?;
write_agent_state(&state_path, &record)?;
record
}
};
if record.phase != AGENT_PHASE_ACTIVE {
return Err("上次配置操作尚未完整结束,请先关闭开关恢复原配置".to_string());
}
let conflicts = record_conflict_files(&record)?;
if !conflicts.is_empty() {
return Err(format!(
"配置已被其他程序修改,无法更新: {}",
conflicts.join("、")
));
}
let updates = build_agent_updates(client, home, port, model, models, codex_models)?;
let (mut next, backup_snapshots) = extend_agent_record_for_updates(&record, &updates)?;
next.phase = AGENT_PHASE_APPLYING.to_string();
next.model = model.to_string();
for (file, update) in next.files.iter_mut().zip(&updates) {
if file.path != update.path {
return Err("智能体配置更新路径不匹配".to_string());
}
file.managed_sha256 = sha256_bytes(update.after.as_bytes());
}
if let Err(error) = write_agent_state(&state_path, &next) {
let rollback = restore_snapshots(&backup_snapshots);
return Err(match rollback {
Ok(()) => error,
Err(rollback_error) => format!("{error};恢复模型目录备份失败: {rollback_error}"),
});
}
match apply_agent_updates(&updates) {
Ok(changed) => {
next.phase = AGENT_PHASE_ACTIVE.to_string();
write_agent_state(&state_path, &next)?;
Ok(action_result(
"updated",
true,
Some(model.to_string()),
changed,
Vec::new(),
))
}
Err(error) => {
let state_rollback = write_agent_state(&state_path, &record).err();
let backup_rollback = restore_snapshots(&backup_snapshots).err();
let mut errors = vec![error];
if let Some(rollback_error) = state_rollback {
errors.push(format!("恢复原状态失败: {rollback_error}"));
}
if let Some(rollback_error) = backup_rollback {
errors.push(format!("恢复模型目录备份失败: {rollback_error}"));
}
Err(errors.join(";"))
}
}
}
#[tauri::command]
fn get_lan_ipv4() -> Option<String> {
detect_lan_ipv4().map(|address| address.to_string())
}
fn detect_lan_ipv4() -> Option<Ipv4Addr> {
for target in ["192.0.2.1:80", "8.8.8.8:80"] {
let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) else {
continue;
};
if socket.connect(target).is_err() {
continue;
}
let Ok(local_address) = socket.local_addr() else {
continue;
};
let IpAddr::V4(address) = local_address.ip() else {
continue;
};
if !address.is_unspecified() && !address.is_loopback() {
return Some(address);
}
}
None
}
#[tauri::command]
fn save_gui_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
cache: tauri::State<'_, AgentConfigStatusCache>,
settings: GuiNetworkSettings,
) -> Result<GuiSettings, String> {
if settings.port == 0 {
return Err("端口必须在 1 到 65535 之间".to_string());
}
let _refresh_guard = cache
.refresh_lock
.lock()
.map_err(|_| "智能体配置状态刷新锁已损坏".to_string())?;
let previous = gui_config_state.snapshot()?;
let mut next = previous.clone();
next.port = settings.port;
next.allow_lan = settings.allow_lan;
patch_core_network_settings(&next)?;
let config = match gui_config_state.update_network(settings.port, settings.allow_lan) {
Ok(config) => config,
Err(error) => {
let rollback_error = patch_core_network_settings(&previous).err();
return Err(match rollback_error {
Some(rollback_error) => {
format!("{error};回滚内核网络配置也失败: {rollback_error}")
}
None => error,
});
}
};
if config.port != previous.port {
if let Err(error) = cache.clear() {
eprintln!("清空智能体配置状态缓存失败: {error}");
}
}
Ok(GuiSettings::from(&config))
}
#[tauri::command]
fn get_core_config_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreConfigView, String> {
let settings = current_core_config_settings(gui_config_state.inner())?;
let config = gui_config_state.sync_core_settings(&settings)?;
let api_keys = gui_api_key_values(&config.api_keys);
if api_keys != settings.api_keys {
patch_core_api_keys(&api_keys)?;
}
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
fn add_core_api_key(
gui_config_state: tauri::State<'_, GuiConfigState>,
api_key: String,
remark: String,
) -> Result<CoreConfigView, String> {
let api_key = api_key.trim().to_string();
let remark = remark.trim().to_string();
validate_core_api_key(&api_key)?;
validate_api_key_remark(&remark)?;
let mut settings = current_core_config_settings(gui_config_state.inner())?;
if api_key == DEFAULT_API_KEY
|| settings
.api_keys
.iter()
.any(|existing| existing == &api_key)
{
return Err("该鉴权密钥已经存在".to_string());
}
if !settings.api_keys.iter().any(|key| key == DEFAULT_API_KEY) {
settings.api_keys.insert(0, DEFAULT_API_KEY.to_string());
}
settings.api_keys.push(api_key);
patch_core_api_keys(&settings.api_keys)?;
let added_api_key = settings.api_keys.last().map(|key| GuiApiKeyEntry {
key: key.clone(),
remark,
});
let config = gui_config_state.sync_core_settings_with_api_key(&settings, added_api_key)?;
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
fn delete_core_api_key(
gui_config_state: tauri::State<'_, GuiConfigState>,
api_key: String,
) -> Result<CoreConfigView, String> {
let api_key = api_key.trim();
if api_key.is_empty() {
return Err("要删除的鉴权密钥不能为空".to_string());
}
if api_key == DEFAULT_API_KEY {
return Err("内置密钥不能删除".to_string());
}
let mut settings = current_core_config_settings(gui_config_state.inner())?;
let index = settings
.api_keys
.iter()
.position(|existing| existing == api_key)
.ok_or_else(|| "要删除的鉴权密钥不存在,请刷新后重试".to_string())?;
settings.api_keys.remove(index);
patch_core_api_keys(&settings.api_keys)?;
let config = gui_config_state.sync_core_settings(&settings)?;
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
fn set_core_management_secret_key(
gui_config_state: tauri::State<'_, GuiConfigState>,
secret_key: String,
) -> Result<CoreConfigView, String> {
let secret_key = secret_key.trim().to_string();
if secret_key != DEFAULT_MANAGEMENT_SECRET_KEY {
return Err("管理密钥统一固定为 123456".to_string());
}
let config =
gui_config_state.set_management_secret_key(DEFAULT_MANAGEMENT_SECRET_KEY.to_string())?;
patch_core_management_secret_key(&config.management_secret_key)?;
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
fn clear_core_management_secret_key(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreConfigView, String> {
let config =
gui_config_state.set_management_secret_key(DEFAULT_MANAGEMENT_SECRET_KEY.to_string())?;
patch_core_management_secret_key(&config.management_secret_key)?;
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
fn set_core_plugins_enabled(
gui_config_state: tauri::State<'_, GuiConfigState>,
enabled: bool,
) -> Result<CoreConfigView, String> {
let mut settings = current_core_config_settings(gui_config_state.inner())?;
settings.plugins_enabled = enabled;
patch_core_plugins_enabled(settings.plugins_enabled)?;
let config = gui_config_state.sync_core_settings(&settings)?;
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
fn set_core_routing_strategy(
gui_config_state: tauri::State<'_, GuiConfigState>,
strategy: String,
) -> Result<CoreConfigView, String> {
validate_routing_strategy(&strategy)?;
let mut settings = current_core_config_settings(gui_config_state.inner())?;
settings.routing_strategy = strategy;
patch_core_routing_strategy(&settings.routing_strategy)?;
let config = gui_config_state.sync_core_settings(&settings)?;
Ok(CoreConfigView::from(&config))
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct OAuthStartResult {
url: String,
state: Option<String>,
opened: bool,
open_error: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct OAuthStatusResult {
status: String,
error: Option<String>,
}
#[derive(Deserialize)]
struct OAuthStartApiResponse {
url: Option<String>,
state: Option<String>,
error: Option<String>,
#[serde(rename = "error_message")]
error_message: Option<String>,
}
#[derive(Deserialize)]
struct OAuthStatusApiResponse {
status: Option<String>,
error: Option<String>,
#[serde(rename = "error_message")]
error_message: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManagementRequest {
method: String,
path: String,
query: Option<std::collections::HashMap<String, String>>,
body: Option<serde_json::Value>,
}
#[tauri::command]
async fn management_request(
gui_config_state: tauri::State<'_, GuiConfigState>,
request: ManagementRequest,
) -> Result<serde_json::Value, String> {
let config = gui_config_state.snapshot()?;
let method = match request.method.trim().to_ascii_uppercase().as_str() {
"GET" => reqwest::Method::GET,
"POST" => reqwest::Method::POST,
"PUT" => reqwest::Method::PUT,
"PATCH" => reqwest::Method::PATCH,
"DELETE" => reqwest::Method::DELETE,
_ => return Err("不支持的管理 API 请求方法".to_string()),
};
let path = request.path.trim();
if path.is_empty() || path.contains("://") || path.contains("..") {
return Err("无效的管理 API 路径".to_string());
}
let client = management_http_client()?;
let mut builder = client
.request(method, management_endpoint(&config, path)?)
.header("Authorization", management_authorization(&config)?);
if let Some(query) = request.query {
builder = builder.query(&query);
}
if let Some(body) = request.body {
builder = builder.json(&body);
}
let response = builder
.send()
.await
.map_err(|err| format!("请求管理 API 失败: {err}"))?;
read_management_value(response).await
}
#[tauri::command]
async fn upload_auth_file(
gui_config_state: tauri::State<'_, GuiConfigState>,
name: String,
data: Vec<u8>,
) -> Result<serde_json::Value, String> {
let name = name.trim().to_string();
if name.is_empty() || !name.to_ascii_lowercase().ends_with(".json") {
return Err("认证文件名必须以 .json 结尾".to_string());
}
let config = gui_config_state.snapshot()?;
let client = management_http_client()?;
let mut query = std::collections::HashMap::new();
query.insert("name".to_string(), name);
let response = client
.post(management_endpoint(&config, "auth-files")?)
.header("Authorization", management_authorization(&config)?)
.query(&query)
.header("Content-Type", "application/json")
.body(data)
.send()
.await
.map_err(|err| format!("上传认证文件失败: {err}"))?;
read_management_value(response).await
}
#[tauri::command]
async fn download_auth_file(
gui_config_state: tauri::State<'_, GuiConfigState>,
name: String,
) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("认证文件名不能为空".to_string());
}
let config = gui_config_state.snapshot()?;
let client = management_http_client()?;
let mut query = std::collections::HashMap::new();
query.insert("name".to_string(), name.to_string());
let response = client
.get(management_endpoint(&config, "auth-files/download")?)
.header("Authorization", management_authorization(&config)?)
.query(&query)
.send()
.await
.map_err(|err| format!("下载认证文件失败: {err}"))?;
read_management_text(response).await
}
#[tauri::command]
async fn start_oauth_login(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
provider: String,
) -> Result<OAuthStartResult, String> {
let config = gui_config_state.snapshot()?;
let provider_key = normalize_management_oauth_provider(&provider)?;
let client = management_http_client()?;
let mut request = client
.get(management_endpoint(
&config,
&format!("{provider_key}-auth-url"),
)?)
.header("Authorization", management_authorization(&config)?);
if management_oauth_uses_webui_callback(&provider_key) {
request = request.query(&[("is_webui", "true")]);
}
let response = request
.send()
.await
.map_err(|err| format!("请求 OAuth 登录链接失败: {err}"))?;
let payload = read_management_json::<OAuthStartApiResponse>(response).await?;
if let Some(error) = payload
.error
.or(payload.error_message)
.filter(|value| !value.trim().is_empty())
{
return Err(error);
}
let url = payload
.url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| "内核未返回 OAuth 登录链接".to_string())?;
let state = payload
.state
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let (opened, open_error) = match open_external_url_inner(&app, &url) {
Ok(()) => (true, None),
Err(error) => (false, Some(error)),
};
Ok(OAuthStartResult {
url,
state,
opened,
open_error,
})
}
#[tauri::command]
async fn get_oauth_status(
gui_config_state: tauri::State<'_, GuiConfigState>,
state: String,
) -> Result<OAuthStatusResult, String> {
let state = state.trim().to_string();
if state.is_empty() {
return Err("OAuth state 不能为空".to_string());
}
let config = gui_config_state.snapshot()?;
let client = management_http_client()?;
let response = client
.get(management_endpoint(&config, "get-auth-status")?)
.header("Authorization", management_authorization(&config)?)
.query(&[("state", state)])
.send()
.await
.map_err(|err| format!("查询 OAuth 状态失败: {err}"))?;
let payload = read_management_json::<OAuthStatusApiResponse>(response).await?;
let status = payload
.status
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "wait".to_string());
Ok(OAuthStatusResult {
status,
error: payload
.error
.or(payload.error_message)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
})
}
#[tauri::command]
async fn submit_oauth_callback(
gui_config_state: tauri::State<'_, GuiConfigState>,
provider: String,
redirect_url: String,
) -> Result<(), String> {
let redirect_url = redirect_url.trim().to_string();
if redirect_url.is_empty() {
return Err("回调链接不能为空".to_string());
}
let config = gui_config_state.snapshot()?;
let provider_key = normalize_management_oauth_provider(&provider)?;
let client = management_http_client()?;
let body = serde_json::json!({
"provider": provider_key,
"redirect_url": redirect_url,
});
let response = client
.post(management_endpoint(&config, "oauth-callback")?)
.header("Authorization", management_authorization(&config)?)
.json(&body)
.send()
.await
.map_err(|err| format!("提交 OAuth 回调失败: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("读取 OAuth 回调响应失败: {err}"))?;
if !status.is_success() {
return Err(format_management_error(status.as_u16(), &text));
}
Ok(())
}
#[tauri::command]
fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
open_external_url_inner(&app, &url)
}
#[tauri::command]
async fn check_app_update() -> Result<AppUpdateInfo, String> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(8))
.read_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| format!("创建版本检查客户端失败: {error}"))?;
let response = client
.get(APP_RELEASE_PAGE_URL)
.header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("检查软件更新失败: {error}"))?;
let status = response.status();
let final_url = response.url().clone();
if !status.is_success() {
let body = response
.text()
.await
.map_err(|error| format!("读取软件更新响应失败: {error}"))?;
return Err(format_github_error(status.as_u16(), &body));
}
let latest_version = release_tag_from_url(&final_url)
.map(|version| normalize_version(&version))
.ok_or_else(|| "GitHub latest 页面没有返回正式版本标签".to_string())?;
let current_version = normalize_version(env!("CARGO_PKG_VERSION"));
let update_available = is_app_update_available(&current_version, &latest_version)?;
Ok(AppUpdateInfo {
current_version,
latest_version,
update_available,
release_url: APP_RELEASE_PAGE_URL.to_string(),
})
}
#[tauri::command]
async fn check_latest_core() -> Result<CoreLatest, String> {
let platform = current_core_platform()?;
let client = http_client()?;
let release = fetch_release(&client, None).await?;
let asset = select_release_asset(&release, &platform)?;
Ok(CoreLatest {
version: normalize_version(&release.tag_name),
asset_name: asset.name.clone(),
})
}
#[tauri::command]
fn detect_bundled_core() -> Result<Option<BundledCoreInfo>, String> {
bundled_core_archive().map(|value| value.map(|(info, _)| info))
}
#[tauri::command]
fn install_bundled_core(
window: tauri::Window,
state: tauri::State<'_, CoreDownloadState>,
) -> Result<CoreInstallResult, String> {
let (info, archive_path) = bundled_core_archive()?
.ok_or_else(|| "当前发行包没有匹配此系统架构的内置内核".to_string())?;
let token = CancellationToken::new();
state.start(token, Some(info.version.clone()))?;
let result = install_bundled_core_inner(&window, state.inner(), &info, &archive_path);
if result.is_err() {
let _ = cleanup_core_work_dirs();
}
state.finish(&window, result.clone());
result
}
#[tauri::command]
fn cancel_core_install(state: tauri::State<'_, CoreDownloadState>) {
state.cancel();
}
#[tauri::command]
fn get_core_install_task(state: tauri::State<'_, CoreDownloadState>) -> CoreInstallTask {
state.snapshot()
}
#[tauri::command]
async fn install_core_version(
window: tauri::Window,
state: tauri::State<'_, CoreDownloadState>,
version: Option<String>,
) -> Result<CoreInstallResult, String> {
let token = CancellationToken::new();
state.start(token.clone(), version.clone())?;
let result = install_core_version_inner(&window, state.inner(), token, version).await;
if result.is_err() {
let _ = cleanup_core_work_dirs();
}
state.finish(&window, result.clone());
result
}
#[tauri::command]
fn start_core_process(
app: tauri::AppHandle,
process_state: tauri::State<'_, CoreProcessState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreStatus, String> {
let status = start_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
emit_core_status(&app, &status);
Ok(status)
}
fn start_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
start_core_process_inner(process_state, &config)?;
if let Err(error) = gui_config_state.set_run_on_startup(true) {
let _ = stop_core_process_inner(process_state);
return Err(error);
}
current_core_status(Some(process_state), Some(config.port))
}
#[tauri::command]
fn stop_core_process(
app: tauri::AppHandle,
process_state: tauri::State<'_, CoreProcessState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreStatus, String> {
let status = stop_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
emit_core_status(&app, &status);
Ok(status)
}
fn stop_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
stop_core_process_inner(process_state)?;
let config = gui_config_state.set_run_on_startup(false)?;
current_core_status(Some(process_state), Some(config.port))
}
#[tauri::command]
fn restart_core_process(
app: tauri::AppHandle,
process_state: tauri::State<'_, CoreProcessState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreStatus, String> {
let status = restart_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
emit_core_status(&app, &status);
Ok(status)
}
fn restart_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
let _ = stop_core_process_inner(process_state);
start_core_process_inner(process_state, &config)?;
if let Err(error) = gui_config_state.set_run_on_startup(true) {
let _ = stop_core_process_inner(process_state);
return Err(error);
}
current_core_status(Some(process_state), Some(config.port))
}
async fn install_core_version_inner(
window: &tauri::Window,
state: &CoreDownloadState,
token: CancellationToken,
version: Option<String>,
) -> Result<CoreInstallResult, String> {
let platform = current_core_platform()?;
let client = http_client()?;
state.progress(window, "检查版本", 0, None, true);
let release = fetch_release_cancelable(&client, version.as_deref(), &token).await?;
let asset = select_release_asset(&release, &platform)?;
let install_dir = core_install_dir()?;
let base_dir = core_base_dir()?;
let staging_dir = base_dir.join("cpa-core.staging");
let backup_dir = base_dir.join("cpa-core.backup");
let download_dir = base_dir.join("cpa-core.download");
if current_core_status(None, None)?.running {
return Err("CPA 内核正在运行,请先停止后再安装或更新".to_string());
}
reset_dir(&staging_dir)?;
reset_dir(&download_dir)?;
let archive_file_name = Path::new(&asset.name)
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| format!("非法 asset 文件名: {}", asset.name))?;
let archive_path = download_dir.join(archive_file_name);
let downloaded = download_asset(
&client,
&asset.browser_download_url,
&archive_path,
asset.size,
asset.digest.as_deref(),
window,
state,
&token,
)
.await?;
validate_downloaded_asset(asset, &downloaded)?;
ensure_not_cancelled(&token, Some(&archive_path))?;
state.progress(
window,
"解压中",
downloaded.size,
Some(downloaded.size),
false,
);
match platform.archive_kind.as_str() {
"tar.gz" => extract_tar_gz(&archive_path, &staging_dir)?,
"zip" => extract_zip(&archive_path, &staging_dir)?,
other => return Err(format!("不支持的压缩包类型: {other}")),
}
ensure_not_cancelled(&token, Some(&archive_path))?;
let binary_path = find_core_binary(&staging_dir)
.ok_or_else(|| "解压后未找到 CPA 内核二进制文件".to_string())?;
let binary_relative_path = binary_path
.strip_prefix(&staging_dir)
.map_err(|err| format!("计算内核二进制相对路径失败: {err}"))?
.to_path_buf();
preserve_core_runtime_files(&install_dir, &staging_dir)?;
preserve_bundled_core_assets(&install_dir, &staging_dir)?;
write_core_metadata(
&staging_dir,
&CoreMetadata {
version: normalize_version(&release.tag_name),
asset_name: asset.name.clone(),
installed_at_unix: unix_now(),
},
)?;
replace_install_dir(&install_dir, &staging_dir, &backup_dir)?;
let _ = fs::remove_dir_all(&download_dir);
Ok(CoreInstallResult {
version: normalize_version(&release.tag_name),
asset_name: asset.name.clone(),
install_dir: path_to_string(&install_dir),
binary_path: Some(path_to_string(&install_dir.join(binary_relative_path))),
})
}
fn install_bundled_core_inner(
window: &tauri::Window,
state: &CoreDownloadState,
info: &BundledCoreInfo,
archive_path: &Path,
) -> Result<CoreInstallResult, String> {
let platform = current_core_platform()?;
let install_dir = core_install_dir()?;
let base_dir = core_base_dir()?;
let staging_dir = base_dir.join("cpa-core.staging");
let backup_dir = base_dir.join("cpa-core.backup");
if current_core_status(None, None)?.running {
return Err("CPA 内核正在运行,请先停止后再使用内置内核".to_string());
}
let archive_size = fs::metadata(archive_path)
.map_err(|error| format!("读取内置内核压缩包失败: {error}"))?
.len();
state.progress(window, "校验内置内核", 0, Some(archive_size), false);
validate_bundled_core_checksum(archive_path)?;
reset_dir(&staging_dir)?;
state.progress(
window,
"解压内置内核",
archive_size,
Some(archive_size),
false,
);
match platform.archive_kind.as_str() {
"tar.gz" => extract_tar_gz(archive_path, &staging_dir)?,
"zip" => extract_zip(archive_path, &staging_dir)?,
other => return Err(format!("不支持的内置压缩包类型: {other}")),
}
let binary_path = find_core_binary(&staging_dir)
.ok_or_else(|| "内置压缩包中没有 CPA 内核二进制文件".to_string())?;
let binary_relative_path = binary_path
.strip_prefix(&staging_dir)
.map_err(|error| format!("计算内置内核二进制路径失败: {error}"))?
.to_path_buf();
preserve_core_runtime_files(&install_dir, &staging_dir)?;
preserve_bundled_core_assets(&install_dir, &staging_dir)?;
preserve_selected_bundled_core_asset(archive_path, &staging_dir)?;
write_core_metadata(
&staging_dir,
&CoreMetadata {
version: info.version.clone(),
asset_name: info.asset_name.clone(),
installed_at_unix: unix_now(),
},
)?;
replace_install_dir(&install_dir, &staging_dir, &backup_dir)?;
Ok(CoreInstallResult {
version: info.version.clone(),
asset_name: info.asset_name.clone(),
install_dir: path_to_string(&install_dir),
binary_path: Some(path_to_string(&install_dir.join(binary_relative_path))),
})
}
async fn fetch_release(
client: &reqwest::Client,
version: Option<&str>,
) -> Result<GithubRelease, String> {
if let Some(version) = version {
return Ok(release_from_tag(version));
}
let atom_result = fetch_release_from_atom(client).await;
match atom_result {
Ok(release) => Ok(release),
Err(atom_error) => fetch_release_from_page(client).await.map_err(|page_error| {
format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}")
}),
}
}
async fn fetch_release_from_page(client: &reqwest::Client) -> Result<GithubRelease, String> {
let response = client
.get(RELEASE_PAGE_URL)
.header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml")
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|err| format!("GitHub release 页面请求失败: {err}"))?;
let status = response.status();
let final_url = response.url().clone();
if !status.is_success() {
let body = response
.text()
.await
.map_err(|err| format!("读取 GitHub release 页面失败: {err}"))?;
return Err(format_github_error(status.as_u16(), &body));
}
let tag = release_tag_from_url(&final_url)
.ok_or_else(|| "GitHub release 页面没有返回版本标签".to_string())?;
Ok(release_from_tag(&tag))
}
async fn fetch_release_from_atom(client: &reqwest::Client) -> Result<GithubRelease, String> {
let response = client
.get(RELEASE_ATOM_URL)
.header(
reqwest::header::ACCEPT,
"application/atom+xml,application/xml,text/xml",
)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|err| format!("GitHub Atom feed 请求失败: {err}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|err| format!("读取 GitHub Atom feed 失败: {err}"))?;
if !status.is_success() {
return Err(format_github_error(status.as_u16(), &body));
}
let tag = release_tag_from_atom(&body)
.ok_or_else(|| "GitHub Atom feed 没有返回版本标签".to_string())?;
Ok(release_from_tag(&tag))
}
fn release_tag_from_atom(xml: &str) -> Option<String> {
let entry = xml.split_once("<entry>")?.1;
if let Some(tag_path) = entry.split_once("/releases/tag/").map(|(_, value)| value) {
let tag = tag_path
.split(['\"', '<', '?', '#'])
.next()
.unwrap_or_default()
.trim_matches('/');
if !tag.is_empty() {
return Some(normalize_version(tag));
}
}
let title = entry
.split_once("<title>")?
.1
.split_once("</title>")?
.0
.trim();
(!title.is_empty()).then(|| normalize_version(title))
}
fn release_from_tag(tag: &str) -> GithubRelease {
let tag = normalize_version(tag);
let version = tag.trim_start_matches('v');
let assets = [
("linux", "amd64", "tar.gz"),
("linux", "aarch64", "tar.gz"),
("darwin", "amd64", "tar.gz"),
("darwin", "aarch64", "tar.gz"),
("windows", "amd64", "zip"),
("windows", "aarch64", "zip"),
]
.into_iter()
.map(|(os, arch, extension)| {
let name = format!("CLIProxyAPI_{version}_{os}_{arch}.{extension}");
GithubAsset {
browser_download_url: format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}"),
name,
size: None,
digest: None,
}
})
.collect();
GithubRelease {
tag_name: tag,
assets,
}
}
fn release_tag_from_url(url: &reqwest::Url) -> Option<String> {
let mut segments = url.path_segments()?;
let tag = segments.next_back()?.trim();
if tag.is_empty() || tag == "latest" {
None
} else {
Some(tag.to_string())
}
}
fn is_app_update_available(current: &str, latest: &str) -> Result<bool, String> {
let parse = |value: &str| {
semver::Version::parse(value.trim().trim_start_matches('v'))
.map_err(|error| format!("无法解析版本号 {value}: {error}"))
};
Ok(parse(latest)? > parse(current)?)
}
#[cfg(test)]
fn parse_release_assets(html: &str) -> Vec<GithubAsset> {
let mut assets = Vec::new();
let mut cursor = 0;
while let Some(relative_start) = html[cursor..].find("releases/download/") {
let download_start = cursor + relative_start;
let Some(href_start) = html[..download_start].rfind("href=\"") else {
cursor = download_start + "releases/download/".len();
continue;
};
let href_start = href_start + "href=\"".len();
let Some(relative_end) = html[download_start..].find('"') else {
break;
};
let href_end = download_start + relative_end;
let href = &html[href_start..href_end];
let Some(name) = href.rsplit('/').next().filter(|name| !name.is_empty()) else {
cursor = href_end + 1;
continue;
};
let item_end = html[href_end..]
.find("</li>")
.map(|offset| href_end + offset)
.unwrap_or(html.len());
let item = &html[href_start..item_end];
let digest = item.find("sha256:").and_then(|offset| {
let value = &item[offset + "sha256:".len()..];
let hash: String = value
.chars()
.take_while(|character| character.is_ascii_hexdigit())
.collect();
(hash.len() == 64).then(|| format!("sha256:{hash}"))
});
let browser_download_url = if href.starts_with("http://") || href.starts_with("https://") {
href.to_string()
} else {
format!("https://github.com{href}")
};
if !assets.iter().any(|asset: &GithubAsset| asset.name == name) {
assets.push(GithubAsset {
name: name.to_string(),
browser_download_url,
size: None,
digest,
});
}
cursor = href_end + 1;
}
assets
}
fn format_github_error(status: u16, body: &str) -> String {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(message) = value.get("message").and_then(|item| item.as_str()) {
return format!("GitHub 返回错误 ({status}): {}", message.trim());
}
}
let body = body.trim();
if body.is_empty() {
format!("GitHub 返回错误 ({status})")
} else {
format!("GitHub 返回错误 ({status}): {}", truncate_for_error(body))
}
}
async fn fetch_release_cancelable(
client: &reqwest::Client,
version: Option<&str>,
token: &CancellationToken,
) -> Result<GithubRelease, String> {
tokio::select! {
result = fetch_release(client, version) => result,
_ = token.cancelled() => Err("已取消下载".to_string()),
}
}
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(600))
.build()
.map_err(|err| format!("创建 HTTP 客户端失败: {err}"))
}
fn select_release_asset<'a>(
release: &'a GithubRelease,
platform: &CorePlatform,
) -> Result<&'a GithubAsset, String> {
let expected_name = core_release_asset_name(&release.tag_name, platform);
let mut matches = release
.assets
.iter()
.filter(|asset| asset.name == expected_name && !asset.name.contains("_no-plugin"));
let asset = matches
.next()
.ok_or_else(|| format!("未找到匹配当前平台的 release asset: {expected_name}"))?;
if matches.next().is_some() {
return Err(format!("找到多个匹配的 release asset: {expected_name}"));
}
Ok(asset)
}
fn core_release_asset_name(version: &str, platform: &CorePlatform) -> String {
let version = normalize_version(version);
let version = version.trim_start_matches('v');
format!(
"CLIProxyAPI_{}_{}_{}.{}",
version, platform.asset_os, platform.asset_arch, platform.archive_kind
)
}
// Download progress and cancellation require the complete transfer context here.
#[allow(clippy::too_many_arguments)]
async fn download_asset(
client: &reqwest::Client,
url: &str,
archive_path: &Path,
expected_total: Option<u64>,
expected_digest: Option<&str>,
window: &tauri::Window,
state: &CoreDownloadState,
token: &CancellationToken,
) -> Result<DownloadedArchive, String> {
let result = download_asset_inner(
client,
url,
archive_path,
expected_total,
expected_digest,
window,
state,
token,
)
.await;
if result.is_err() {
let _ = fs::remove_file(archive_path);
}
result
}
#[allow(clippy::too_many_arguments)]
async fn download_asset_inner(
client: &reqwest::Client,
url: &str,
archive_path: &Path,
expected_total: Option<u64>,
expected_digest: Option<&str>,
window: &tauri::Window,
state: &CoreDownloadState,
token: &CancellationToken,
) -> Result<DownloadedArchive, String> {
state.progress(window, "准备下载", 0, expected_total, true);
ensure_not_cancelled(token, Some(archive_path))?;
let request = client
.get(url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send();
let response = tokio::select! {
response = request => response.map_err(|err| format!("下载内核压缩包失败: {err}"))?,
_ = token.cancelled() => return Err("已取消下载".to_string()),
}
.error_for_status()
.map_err(|err| format!("下载地址返回错误状态: {err}"))?;
let total = expected_total.or_else(|| response.content_length());
let mut stream = response.bytes_stream();
let mut file =
File::create(archive_path).map_err(|err| format!("创建内核压缩包失败: {err}"))?;
let mut downloaded = 0_u64;
let mut hasher = Sha256::new();
while let Some(chunk) = tokio::select! {
chunk = stream.next() => chunk,
_ = token.cancelled() => return Err("已取消下载".to_string()),
} {
ensure_not_cancelled(token, Some(archive_path))?;
let chunk = chunk.map_err(|err| format!("读取下载数据失败: {err}"))?;
file.write_all(&chunk)
.map_err(|err| format!("保存下载数据失败: {err}"))?;
hasher.update(&chunk);
downloaded += chunk.len() as u64;
state.progress(window, "下载中", downloaded, total, true);
}
file.flush()
.map_err(|err| format!("刷新内核压缩包失败: {err}"))?;
ensure_not_cancelled(token, Some(archive_path))?;
let sha256 = format!("{:x}", hasher.finalize());
validate_download_metadata(downloaded, expected_total, &sha256, expected_digest)?;
Ok(DownloadedArchive {
size: downloaded,
sha256,
})
}
fn ensure_not_cancelled(
token: &CancellationToken,
archive_path: Option<&Path>,
) -> Result<(), String> {
if token.is_cancelled() {
if let Some(archive_path) = archive_path {
let _ = fs::remove_file(archive_path);
}
return Err("已取消下载".to_string());
}
Ok(())
}
fn current_core_platform() -> Result<CorePlatform, String> {
let os = env::consts::OS;
let arch = env::consts::ARCH;
let (asset_os, archive_kind) = match os {
"linux" => ("linux", "tar.gz"),
"macos" => ("darwin", "tar.gz"),
"windows" => ("windows", "zip"),
other => return Err(format!("不支持的操作系统: {other}")),
};
let asset_arch = match arch {
"x86_64" => "amd64",
"aarch64" => "aarch64",
other => return Err(format!("不支持的 CPU 架构: {other}")),
};
Ok(CorePlatform {
os: os.to_string(),
arch: arch.to_string(),
asset_os: asset_os.to_string(),
asset_arch: asset_arch.to_string(),
archive_kind: archive_kind.to_string(),
})
}
fn current_core_status(
process_state: Option<&CoreProcessState>,
management_port: Option<u16>,
) -> Result<CoreStatus, String> {
let install_dir = core_install_dir()?;
let binary_path = find_core_binary(&install_dir);
let installed = binary_path.is_some();
let managed_pid = process_state.and_then(|state| state.managed_pid());
let management_port_open = management_port.map(is_management_port_open);
let process_id = match managed_pid {
Some(process_id) => Some(process_id),
None if management_port_open.unwrap_or(true) => binary_path
.as_ref()
.and_then(|path| find_core_process_ids(path).first().copied()),
None => None,
};
let running = process_id.is_some() && management_port_open.unwrap_or(true);
let current_version = read_core_metadata(&install_dir).map(|metadata| metadata.version);
let message = if !installed {
"未安装 CPA 内核,请先安装最新版".to_string()
} else if running {
"CPA 内核正在运行".to_string()
} else {
"CPA 内核已安装,当前未运行".to_string()
};
Ok(CoreStatus {
installed,
running,
managed: managed_pid.is_some(),
process_id,
current_version,
install_dir: path_to_string(&install_dir),
binary_path: binary_path.map(|path| path_to_string(&path)),
message,
})
}
fn is_management_port_open(port: u16) -> bool {
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok()
}
fn start_core_process_inner(
process_state: &CoreProcessState,
gui_config: &GuiConfigFile,
) -> Result<(), String> {
ensure_fixed_oauth_dir()?;
let install_dir = core_install_dir()?;
let binary_path = find_core_binary(&install_dir)
.ok_or_else(|| "未安装 CPA 内核,请先安装最新版".to_string())?;
if process_state.managed_pid().is_some() || is_core_running(&binary_path) {
return Err("CPA 内核已经在运行".to_string());
}
let management_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), gui_config.port);
if TcpStream::connect_timeout(&management_address, Duration::from_millis(250)).is_ok() {
return Err(format!(
"端口 {} 已被其他程序占用,请更换端口后重试",
gui_config.port
));
}
let config_path = merge_core_config_for_start(&install_dir, gui_config)?;
let config_path = path_to_string(&config_path);
let mut command = Command::new(&binary_path);
command
.args(["-config", &config_path])
.current_dir(&install_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
configure_child_lifetime(&mut command);
let mut child = command
.spawn()
.map_err(|err| format!("启动 CPA 内核失败: {err}"))?;
if let Err(error) = wait_for_core_management_port(&mut child, management_address) {
let _ = terminate_child(&mut child);
return Err(error);
}
process_state.store_child(child)?;
Ok(())
}
fn wait_for_core_management_port(child: &mut Child, address: SocketAddr) -> Result<(), String> {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(status) = child
.try_wait()
.map_err(|err| format!("检查 CPA 内核启动状态失败: {err}"))?
{
return Err(format!("CPA 内核启动后立即退出: {status}"));
}
if TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"CPA 内核启动超时:10 秒内未监听管理端口 {}",
address.port()
));
}
thread::sleep(Duration::from_millis(100));
}
}
fn configure_child_lifetime(command: &mut Command) {
#[cfg(target_os = "linux")]
{
use std::os::unix::process::CommandExt;
let parent_process_id = unsafe { libc::getpid() };
unsafe {
command.pre_exec(move || {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
return Err(io::Error::last_os_error());
}
if libc::getppid() != parent_process_id {
return Err(io::Error::other(
"EasyCLIProxyAPI exited before the core process started",
));
}
Ok(())
});
}
}
#[cfg(not(target_os = "linux"))]
let _ = command;
}
fn configure_background_command(command: &mut Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = command;
}
fn merge_core_config_for_start(
install_dir: &Path,
gui_config: &GuiConfigFile,
) -> Result<PathBuf, String> {
let _config_guard = lock_core_config_file()?;
let config_path = install_dir.join(CORE_CONFIG_FILE);
let example_config_path = install_dir.join(CORE_EXAMPLE_CONFIG_FILE);
if !example_config_path.is_file() {
return Err(format!(
"未找到内核配置模板: {}",
path_to_string(&example_config_path)
));
}
let template = fs::read_to_string(&example_config_path).map_err(|err| {
format!(
"读取内核配置模板失败 {}: {err}",
path_to_string(&example_config_path)
)
})?;
let current = if config_path.is_file() {
Some(fs::read_to_string(&config_path).map_err(|err| {
format!(
"读取现有内核配置失败 {}: {err}",
path_to_string(&config_path)
)
})?)
} else {
None
};
let merged = merge_core_config_yaml(&template, current.as_deref(), gui_config)?;
write_yaml_if_changed(&config_path, &merged)?;
Ok(config_path)
}
fn patch_core_network_settings(config: &GuiConfigFile) -> Result<(), String> {
let _config_guard = lock_core_config_file()?;
let install_dir = core_install_dir()?;
let config_path = install_dir.join(CORE_CONFIG_FILE);
if !config_path.is_file() {
return Ok(());
}
let content = fs::read_to_string(&config_path)
.map_err(|err| format!("读取内核配置失败 {}: {err}", path_to_string(&config_path)))?;
let Some(updated) = patch_core_network_yaml(&content, config)? else {
return Ok(());
};
write_yaml_if_changed(&config_path, &updated).map(|_| ())
}
fn patch_core_auth_dir(auth_dir: &str) -> Result<(), String> {
let auth_dir = auth_dir.to_string();
patch_existing_core_config(move |document| {
set_core_yaml_top_level_value(document, "auth-dir", serde_norway::Value::String(auth_dir))
})
}
fn lock_core_config_file() -> Result<std::sync::MutexGuard<'static, ()>, String> {
CORE_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "内核配置文件锁已损坏".to_string())
}
fn read_installed_core_config_settings() -> Result<CoreConfigSettings, String> {
let _config_guard = lock_core_config_file()?;
let (_, document) = read_core_config_document()?;
core_config_settings_from_value(document.get())
}
fn current_core_config_settings(
gui_config_state: &GuiConfigState,
) -> Result<CoreConfigSettings, String> {
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
if config_path.is_file() {
read_installed_core_config_settings()
} else {
let config = gui_config_state.snapshot()?;
Ok(CoreConfigSettings::from(&config))
}
}
fn patch_core_api_keys(api_keys: &[String]) -> Result<(), String> {
let _config_guard = lock_core_config_file()?;
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
if !config_path.is_file() {
return Ok(());
}
let content = fs::read_to_string(&config_path)
.map_err(|err| format!("读取内核配置失败 {}: {err}", path_to_string(&config_path)))?;
let updated = patch_core_api_keys_yaml(&content, api_keys)?;
write_yaml_if_changed(&config_path, &updated)?;
Ok(())
}
fn patch_core_management_secret_key(secret_key: &str) -> Result<(), String> {
let _ = secret_key;
let secret_key = DEFAULT_MANAGEMENT_SECRET_KEY.to_string();
patch_existing_core_config(move |document| {
set_core_yaml_nested_value(
document,
"remote-management",
"secret-key",
serde_norway::Value::String(secret_key),
)
})
}
fn patch_core_plugins_enabled(enabled: bool) -> Result<(), String> {
patch_existing_core_config(|document| {
set_core_yaml_nested_value(
document,
"plugins",
"enabled",
serde_norway::Value::Bool(enabled),
)
})
}
fn patch_core_routing_strategy(strategy: &str) -> Result<(), String> {
let strategy = strategy.to_string();
patch_existing_core_config(move |document| {
set_core_yaml_nested_value(
document,
"routing",
"strategy",
serde_norway::Value::String(strategy),
)
})
}
fn patch_existing_core_config<F>(update: F) -> Result<(), String>
where
F: FnOnce(&mut serde_norway::Value) -> Result<bool, String>,
{
let _config_guard = lock_core_config_file()?;
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
if !config_path.is_file() {
return Ok(());
}
let content = fs::read_to_string(&config_path)
.map_err(|err| format!("读取内核配置失败 {}: {err}", path_to_string(&config_path)))?;
let Some(updated) = patch_core_yaml_document(&content, update)? else {
return Ok(());
};
write_yaml_if_changed(&config_path, &updated)?;
Ok(())
}
fn patch_core_yaml_document<F>(content: &str, update: F) -> Result<Option<String>, String>
where
F: FnOnce(&mut serde_norway::Value) -> Result<bool, String>,
{
let original = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|err| format!("解析内核配置失败: {err}"))?;
let mut updated = original.clone();
if !update(&mut updated)? {
return Ok(None);
}
render_yaml_value_changes(content, &original, &updated).map(Some)
}
struct YamlValueChange {
path: Vec<String>,
value: serde_norway::Value,
}
fn render_yaml_value_changes(
content: &str,
original: &serde_norway::Value,
updated: &serde_norway::Value,
) -> Result<String, String> {
if original == updated {
return Ok(content.to_string());
}
let mut editable_content = normalize_nested_yaml_comment_indentation(content);
let mut changes = Vec::new();
collect_yaml_value_changes(original, updated, &mut Vec::new(), &mut changes)?;
let mut remaining_changes = Vec::new();
for change in changes {
let sequence_length_changed = matches!(
(yaml_value_at_path(original, &change.path), &change.value),
(
Some(serde_norway::Value::Sequence(original_values)),
serde_norway::Value::Sequence(updated_values)
) if original_values.len() != updated_values.len()
);
if sequence_length_changed {
editable_content =
replace_yaml_sequence_value(&editable_content, &change.path, &change.value)?;
} else {
remaining_changes.push(change);
}
}
let file = editable_content
.parse::<yaml_edit::YamlFile>()
.map_err(|err| format!("解析可编辑内核配置失败: {err}"))?;
let document = file
.document()
.ok_or_else(|| "内核配置没有 YAML 文档".to_string())?;
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
for change in remaining_changes {
set_yaml_edit_mapping_path(&root, &change.path, &change.value)?;
}
let rendered = file.to_string();
let validated = serde_norway::from_str::<serde_norway::Value>(&rendered)
.map_err(|err| format!("验证更新后的内核配置失败: {err}"))?;
if validated != *updated {
return Err("更新后的内核配置与预期值不一致,已拒绝写入".to_string());
}
Ok(rendered)
}
fn normalize_nested_yaml_comment_indentation(content: &str) -> String {
let lines = content.split_inclusive('\n').collect::<Vec<_>>();
let mut normalized = String::with_capacity(content.len());
for (index, line) in lines.iter().enumerate() {
let body = line.trim_end_matches(['\r', '\n']);
let ending = &line[body.len()..];
let trimmed = body.trim_start_matches([' ', '\t']);
let current_indent = body.len().saturating_sub(trimmed.len());
if !trimmed.starts_with('#') {
normalized.push_str(line);
continue;
}
let next = lines.iter().skip(index + 1).find_map(|candidate| {
let candidate = candidate.trim_end_matches(['\r', '\n']);
let trimmed = candidate.trim_start_matches([' ', '\t']);
if trimmed.is_empty() || trimmed.starts_with('#') {
None
} else {
Some((candidate.len().saturating_sub(trimmed.len()), trimmed))
}
});
let Some((next_indent, _)) = next else {
normalized.push_str(line);
continue;
};
let belongs_to_nested_mapping = current_indent < next_indent
&& lines[..index].iter().rev().any(|candidate| {
let candidate = candidate.trim_end_matches(['\r', '\n']);
let trimmed = candidate.trim_start_matches([' ', '\t']);
if trimmed.is_empty() || trimmed.starts_with('#') {
return false;
}
let indent = candidate.len().saturating_sub(trimmed.len());
indent < next_indent && trimmed.ends_with(':')
});
if belongs_to_nested_mapping {
normalized.push_str(&" ".repeat(next_indent));
normalized.push_str(trimmed);
normalized.push_str(ending);
} else {
normalized.push_str(line);
}
}
normalized
}
fn collect_yaml_value_changes(
original: &serde_norway::Value,
updated: &serde_norway::Value,
path: &mut Vec<String>,
changes: &mut Vec<YamlValueChange>,
) -> Result<(), String> {
if original == updated {
return Ok(());
}
match (original, updated) {
(
serde_norway::Value::Mapping(original_mapping),
serde_norway::Value::Mapping(updated_mapping),
) => {
for key in original_mapping.keys() {
if !updated_mapping.contains_key(key) {
return Err("当前内核配置更新不支持删除任意 YAML 字段".to_string());
}
}
for (key, updated_value) in updated_mapping {
let key = key
.as_str()
.ok_or_else(|| "内核配置映射键必须是字符串".to_string())?;
path.push(key.to_string());
if let Some(original_value) = original_mapping.get(yaml_key(key)) {
collect_yaml_value_changes(original_value, updated_value, path, changes)?;
} else {
changes.push(YamlValueChange {
path: path.clone(),
value: updated_value.clone(),
});
}
path.pop();
}
Ok(())
}
_ if path.is_empty() => Err("内核配置顶层必须保持为 YAML 映射".to_string()),
_ => {
changes.push(YamlValueChange {
path: path.clone(),
value: updated.clone(),
});
Ok(())
}
}
}
fn yaml_value_at_path<'a>(
root: &'a serde_norway::Value,
path: &[String],
) -> Option<&'a serde_norway::Value> {
path.iter().try_fold(root, |current, key| {
current.as_mapping()?.get(yaml_key(key))
})
}
fn replace_yaml_sequence_value(
content: &str,
path: &[String],
value: &serde_norway::Value,
) -> Result<String, String> {
let file = content
.parse::<yaml_edit::YamlFile>()
.map_err(|err| format!("解析可编辑内核配置失败: {err}"))?;
let document = file
.document()
.ok_or_else(|| "内核配置没有 YAML 文档".to_string())?;
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let node = yaml_edit_node_at_path(&root, path)
.ok_or_else(|| format!("未找到内核配置序列 {}", path.join(".")))?;
let sequence = node
.as_sequence()
.ok_or_else(|| format!("内核配置字段 {} 必须是 YAML 序列", path.join(".")))?;
let range = sequence.byte_range();
let start = range.start as usize;
let end = range.end as usize;
let line_start = content[..start]
.rfind('\n')
.map(|index| index + 1)
.unwrap_or(0);
let prefix = &content[line_start..start];
let original = &content[start..end];
let trimmed_end = original.trim_end_matches(char::is_whitespace).len();
let trailing = &original[trimmed_end..];
let serialized = if prefix.trim().is_empty() {
let serialized = serde_norway::to_string(value)
.map_err(|err| format!("序列化内核配置序列失败: {err}"))?;
let serialized = serialized.trim_end_matches(['\r', '\n']);
let mut indented = String::with_capacity(serialized.len() + prefix.len() * 2);
for (index, line) in serialized.split('\n').enumerate() {
if index > 0 {
indented.push('\n');
indented.push_str(prefix);
}
indented.push_str(line);
}
indented
} else {
serde_json::to_string(value).map_err(|err| format!("序列化内核配置序列失败: {err}"))?
};
Ok(format!(
"{}{}{}{}",
&content[..start],
serialized,
trailing,
&content[end..]
))
}
fn yaml_edit_node_at_path(
mapping: &yaml_edit::Mapping,
path: &[String],
) -> Option<yaml_edit::YamlNode> {
let (key, remaining) = path.split_first()?;
let node = mapping.get(key.as_str())?;
if remaining.is_empty() {
return Some(node);
}
let child = node.as_mapping()?;
yaml_edit_node_at_path(child, remaining)
}
fn set_yaml_edit_mapping_path(
mapping: &yaml_edit::Mapping,
path: &[String],
value: &serde_norway::Value,
) -> Result<(), String> {
let Some((key, remaining)) = path.split_first() else {
return Err("内核配置更新路径不能为空".to_string());
};
if remaining.is_empty() {
return set_yaml_edit_mapping_value(mapping, key, value);
}
if let Some(child) = mapping.get(key.as_str()) {
let child = child
.as_mapping()
.ok_or_else(|| format!("内核配置区段 {key} 必须是 YAML 映射"))?;
return set_yaml_edit_mapping_path(child, remaining, value);
}
let nested_value = nested_yaml_value_for_path(remaining, value.clone());
set_yaml_edit_mapping_value(mapping, key, &nested_value)
}
fn set_yaml_edit_mapping_value(
mapping: &yaml_edit::Mapping,
key: &str,
value: &serde_norway::Value,
) -> Result<(), String> {
match value {
serde_norway::Value::String(value) => {
mapping.set(key, value.as_str());
return Ok(());
}
serde_norway::Value::Bool(value) => {
mapping.set(key, *value);
return Ok(());
}
serde_norway::Value::Number(value) => {
if let Some(value) = value.as_i64() {
mapping.set(key, value);
return Ok(());
}
if let Some(value) = value.as_u64() {
mapping.set(key, value);
return Ok(());
}
if let Some(value) = value.as_f64() {
mapping.set(key, value);
return Ok(());
}
}
_ => {}
}
if let serde_norway::Value::Sequence(values) = value {
if let Some(node) = mapping.get(key) {
if let Some(sequence) = node.as_sequence() {
if sequence.len() != values.len() {
return Err(format!("内核配置序列 {key} 长度变化未被预处理"));
}
for (index, value) in values.iter().enumerate() {
let value = yaml_edit_node_from_value(value)?;
if !sequence.set(index, value) {
return Err(format!("更新内核配置序列 {key}[{index}] 失败"));
}
}
return Ok(());
}
}
}
match yaml_edit_node_from_value(value)? {
yaml_edit::YamlNode::Scalar(node) => mapping.set(key, node),
yaml_edit::YamlNode::Sequence(node) => mapping.set(key, node),
yaml_edit::YamlNode::Mapping(node) => mapping.set(key, node),
yaml_edit::YamlNode::Alias(node) => mapping.set(key, node),
yaml_edit::YamlNode::TaggedNode(node) => mapping.set(key, node),
}
Ok(())
}
fn nested_yaml_value_for_path(path: &[String], value: serde_norway::Value) -> serde_norway::Value {
path.iter().rev().fold(value, |nested, key| {
let mut mapping = serde_norway::Mapping::new();
mapping.insert(yaml_key(key), nested);
serde_norway::Value::Mapping(mapping)
})
}
fn yaml_edit_node_from_value(value: &serde_norway::Value) -> Result<yaml_edit::YamlNode, String> {
const WRAPPER_KEY: &str = "__cpa_gui_value__";
let value =
serde_json::to_string(value).map_err(|err| format!("序列化内核配置值失败: {err}"))?;
let serialized = format!("{WRAPPER_KEY}: {value}\n");
let file = serialized
.parse::<yaml_edit::YamlFile>()
.map_err(|err| format!("解析内核配置值失败: {err}"))?;
file.document()
.and_then(|document| document.get(WRAPPER_KEY))
.ok_or_else(|| "无法构造内核配置值".to_string())
}
fn set_core_yaml_top_level_value(
document: &mut serde_norway::Value,
key: &str,
value: serde_norway::Value,
) -> Result<bool, String> {
let root = document
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let key = yaml_key(key);
if root.get(&key) == Some(&value) {
return Ok(false);
}
root.insert(key, value);
Ok(true)
}
fn set_core_yaml_nested_value(
document: &mut serde_norway::Value,
section: &str,
key: &str,
value: serde_norway::Value,
) -> Result<bool, String> {
let root = document
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let section = root
.entry(yaml_key(section))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "内核配置区段必须是 YAML 映射".to_string())?;
let key = yaml_key(key);
if section.get(&key) == Some(&value) {
return Ok(false);
}
section.insert(key, value);
Ok(true)
}
fn patch_core_api_keys_yaml(content: &str, api_keys: &[String]) -> Result<String, String> {
let parsed = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|err| format!("解析内核配置失败: {err}"))?;
let root = parsed
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let has_legacy_api_keys = nested_yaml_value(
root,
&["auth", "providers", "config-api-key", "api-key-entries"],
)
.is_some()
|| nested_yaml_value(root, &["auth", "providers", "config-api-key", "api-keys"]).is_some();
let content = if has_legacy_api_keys {
let file = content
.parse::<yaml_edit::YamlFile>()
.map_err(|err| format!("解析内核配置失败: {err}"))?;
let document = file
.document()
.ok_or_else(|| "内核配置没有 YAML 文档".to_string())?;
clear_legacy_api_key_paths(&document);
file.to_string()
} else {
content.to_string()
};
let block = render_core_api_keys_yaml(api_keys)?;
let updated = replace_top_level_yaml_block(&content, "api-keys", &block);
serde_norway::from_str::<serde_norway::Value>(&updated)
.map_err(|err| format!("验证更新后的内核配置失败: {err}"))?;
Ok(updated)
}
fn render_core_api_keys_yaml(api_keys: &[String]) -> Result<String, String> {
if api_keys.is_empty() {
return Ok(String::new());
}
let sequence = serde_norway::Value::Sequence(
api_keys
.iter()
.cloned()
.map(serde_norway::Value::String)
.collect(),
);
let serialized = serde_norway::to_string(&sequence)
.map_err(|err| format!("生成内核鉴权密钥配置失败: {err}"))?;
let mut block = String::from("api-keys:\n");
for line in serialized.lines() {
block.push_str(" ");
block.push_str(line);
block.push('\n');
}
Ok(block)
}
fn replace_top_level_yaml_block(content: &str, key: &str, block: &str) -> String {
let lines = yaml_line_ranges(content);
let key_prefix = format!("{key}:");
if let Some((line_index, (start, end))) =
lines.iter().copied().enumerate().find(|(_, range)| {
let line = yaml_line_content(content, *range);
!line.chars().next().is_some_and(char::is_whitespace) && line.starts_with(&key_prefix)
})
{
let line = yaml_line_content(content, (start, end));
let value = line[key_prefix.len()..].trim();
let mut replace_end = end;
if value.is_empty() || value.starts_with('#') {
for (next_start, next_end) in lines.iter().copied().skip(line_index + 1) {
let next = yaml_line_content(content, (next_start, next_end));
if next.chars().next().is_some_and(char::is_whitespace)
|| is_indentationless_yaml_sequence_item(next)
{
replace_end = next_end;
} else {
break;
}
}
}
return replace_yaml_range(content, start, replace_end, block);
}
let insertion = lines
.iter()
.copied()
.find(|range| yaml_line_content(content, *range).trim() == "# API keys for authentication")
.map(|(_, end)| end)
.or_else(|| {
lines
.iter()
.copied()
.find(|range| {
let line = yaml_line_content(content, *range);
!line.chars().next().is_some_and(char::is_whitespace)
&& line.starts_with("auth-dir:")
})
.map(|(_, end)| end)
})
.unwrap_or(0);
replace_yaml_range(content, insertion, insertion, block)
}
fn is_indentationless_yaml_sequence_item(line: &str) -> bool {
let Some(rest) = line.strip_prefix('-') else {
return false;
};
rest.is_empty() || rest.chars().next().is_some_and(char::is_whitespace)
}
fn yaml_line_ranges(content: &str) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
let mut start = 0;
for (index, character) in content.char_indices() {
if character == '\n' {
ranges.push((start, index + 1));
start = index + 1;
}
}
if start < content.len() {
ranges.push((start, content.len()));
}
ranges
}
fn yaml_line_content(content: &str, (start, end): (usize, usize)) -> &str {
content[start..end].trim_end_matches(['\r', '\n'])
}
fn replace_yaml_range(content: &str, start: usize, end: usize, block: &str) -> String {
let mut result = String::with_capacity(content.len() + block.len());
result.push_str(&content[..start]);
if !block.is_empty() {
result.push_str(block);
if !block.ends_with('\n') && (end < content.len() || content.ends_with('\n')) {
result.push('\n');
}
}
result.push_str(&content[end..]);
result
}
fn clear_legacy_api_key_paths(document: &yaml_edit::Document) {
use yaml_edit::path::YamlPath;
document.remove_path("auth.providers.config-api-key.api-key-entries");
document.remove_path("auth.providers.config-api-key.api-keys");
}
#[cfg(test)]
fn set_yaml_edit_nested_value(
document: &yaml_edit::Document,
section: &str,
key: &str,
value: impl yaml_edit::AsYaml,
) -> bool {
if let Some(node) = document.get(section) {
if let Some(mapping) = node.as_mapping() {
mapping.set(key, value);
return true;
}
}
false
}
fn read_core_config_document() -> Result<(PathBuf, yaml_serde_edit::YamlValue), String> {
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
if !config_path.is_file() {
return Err("内核配置尚未生成,请先启动 CPA 内核".to_string());
}
let content = fs::read_to_string(&config_path)
.map_err(|err| format!("读取内核配置失败 {}: {err}", path_to_string(&config_path)))?;
let document = yaml_serde_edit::YamlValue::parse(&content)
.map_err(|err| format!("解析内核配置失败: {err}"))?;
Ok((config_path, document))
}
fn core_config_settings_from_value(
document: &serde_norway::Value,
) -> Result<CoreConfigSettings, String> {
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let api_keys = extract_core_api_keys(root)?
.into_iter()
.filter(|api_key| !is_example_core_api_key(api_key))
.collect();
let management_secret_key = extract_core_management_secret_key(root)?;
let plugins_enabled = nested_yaml_value(root, &["plugins", "enabled"])
.map(|value| {
value
.as_bool()
.ok_or_else(|| "plugins.enabled 必须是布尔值".to_string())
})
.transpose()?
.unwrap_or(false);
let routing_strategy = nested_yaml_value(root, &["routing", "strategy"])
.map(|value| {
value
.as_str()
.map(str::to_string)
.ok_or_else(|| "routing.strategy 必须是字符串".to_string())
})
.transpose()?
.unwrap_or_else(|| "round-robin".to_string());
Ok(CoreConfigSettings {
api_keys,
management_secret_configured: management_secret_key
.as_deref()
.is_some_and(|value| !value.is_empty()),
plugins_enabled,
routing_strategy,
management_secret_key,
})
}
fn extract_core_api_keys(root: &serde_norway::Mapping) -> Result<Vec<String>, String> {
if let Some(value) = yaml_mapping_value(root, "api-keys") {
return extract_api_key_sequence(value, "api-keys");
}
let legacy = nested_yaml_value(root, &["auth", "providers", "config-api-key"])
.and_then(serde_norway::Value::as_mapping);
let Some(legacy) = legacy else {
return Ok(Vec::new());
};
let value = yaml_mapping_value(legacy, "api-key-entries")
.or_else(|| yaml_mapping_value(legacy, "api-keys"));
value
.map(|value| extract_api_key_sequence(value, "auth.providers.config-api-key"))
.transpose()
.map(Option::unwrap_or_default)
}
fn extract_api_key_sequence(
value: &serde_norway::Value,
field_name: &str,
) -> Result<Vec<String>, String> {
if value.is_null() {
return Ok(Vec::new());
}
let sequence = value
.as_sequence()
.ok_or_else(|| format!("{field_name} 必须是数组"))?;
sequence
.iter()
.filter_map(extract_api_key_value)
.collect::<Result<Vec<_>, _>>()
}
fn extract_api_key_value(value: &serde_norway::Value) -> Option<Result<String, String>> {
if let Some(value) = value.as_str() {
let value = value.trim();
return (!value.is_empty()).then(|| Ok(value.to_string()));
}
let mapping = value.as_mapping()?;
for key in ["api-key", "apiKey", "key", "Key"] {
if let Some(value) = yaml_mapping_value(mapping, key).and_then(serde_norway::Value::as_str)
{
let value = value.trim();
if !value.is_empty() {
return Some(Ok(value.to_string()));
}
}
}
Some(Err(
"鉴权密钥条目必须是字符串或包含 key 字段的映射".to_string()
))
}
fn extract_core_management_secret_key(
root: &serde_norway::Mapping,
) -> Result<Option<String>, String> {
let Some(value) = nested_yaml_value(root, &["remote-management", "secret-key"]) else {
return Ok(None);
};
let value = value
.as_str()
.ok_or_else(|| "remote-management.secret-key 必须是字符串".to_string())?
.trim()
.to_string();
if value.is_empty() || is_hashed_management_secret_key(&value) {
return Ok(None);
}
Ok(Some(value))
}
#[cfg(test)]
fn set_core_api_keys(
document: &mut serde_norway::Value,
api_keys: Vec<String>,
) -> Result<(), String> {
let root = document
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
root.insert(
yaml_key("api-keys"),
serde_norway::Value::Sequence(
api_keys
.into_iter()
.map(serde_norway::Value::String)
.collect(),
),
);
remove_legacy_api_keys(root);
Ok(())
}
#[cfg(test)]
fn remove_legacy_api_keys(root: &mut serde_norway::Mapping) {
let Some(auth) =
yaml_mapping_value_mut(root, "auth").and_then(serde_norway::Value::as_mapping_mut)
else {
return;
};
let Some(providers) =
yaml_mapping_value_mut(auth, "providers").and_then(serde_norway::Value::as_mapping_mut)
else {
return;
};
let Some(provider) = yaml_mapping_value_mut(providers, "config-api-key")
.and_then(serde_norway::Value::as_mapping_mut)
else {
return;
};
provider.remove(yaml_key("api-key-entries"));
provider.remove(yaml_key("api-keys"));
}
#[cfg(test)]
fn set_nested_yaml_value<T>(
document: &mut serde_norway::Value,
path: &[&str],
value: T,
) -> Result<(), String>
where
T: Into<serde_norway::Value>,
{
if path.len() != 2 {
return Err("内核配置路径无效".to_string());
}
let root = document
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let section = root
.entry(yaml_key(path[0]))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()));
let section = section
.as_mapping_mut()
.ok_or_else(|| format!("{} 必须是 YAML 映射", path[0]))?;
section.insert(yaml_key(path[1]), value.into());
Ok(())
}
fn nested_yaml_value<'a>(
root: &'a serde_norway::Mapping,
path: &[&str],
) -> Option<&'a serde_norway::Value> {
let (first, rest) = path.split_first()?;
let mut value = yaml_mapping_value(root, first)?;
for key in rest {
value = yaml_mapping_value(value.as_mapping()?, key)?;
}
Some(value)
}
fn yaml_mapping_value<'a>(
mapping: &'a serde_norway::Mapping,
key: &str,
) -> Option<&'a serde_norway::Value> {
mapping.get(yaml_key(key))
}
fn yaml_mapping_value_mut<'a>(
mapping: &'a mut serde_norway::Mapping,
key: &str,
) -> Option<&'a mut serde_norway::Value> {
mapping.get_mut(yaml_key(key))
}
fn yaml_key(key: &str) -> serde_norway::Value {
serde_norway::Value::String(key.to_string())
}
fn validate_thinking_alias_model_id(value: &str, label: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
return Err(format!("{label}不能为空"));
}
if value.len() > 240
|| value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
return Err(format!("{label}格式无效,不能包含空白字符"));
}
Ok(value.to_string())
}
fn validate_thinking_alias_effort(value: &str) -> Result<String, String> {
let effort = value.trim().to_ascii_lowercase();
if effort.is_empty() {
return Err("思考强度不能为空".to_string());
}
if effort.chars().all(|character| character.is_ascii_digit()) {
return Err("固定思考别名不支持纯数字预算,请输入思考等级名称".to_string());
}
if effort.len() > 64
|| !effort.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
})
{
return Err("思考强度格式无效,仅支持字母、数字、短横线、下划线和点".to_string());
}
Ok(effort)
}
async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Result<String, String> {
let client = management_http_client()?;
let response = client
.get(management_endpoint(config, "config.yaml")?)
.header("Authorization", management_authorization(config)?)
.header(
reqwest::header::ACCEPT,
"application/yaml,text/yaml,text/plain",
)
.send()
.await
.map_err(|error| format!("读取内核 YAML 配置失败: {error}"))?;
read_management_text(response).await
}
async fn put_management_config_yaml(config: &GuiConfigFile, content: &str) -> Result<(), String> {
let client = management_http_client()?;
let response = client
.put(management_endpoint(config, "config.yaml")?)
.header("Authorization", management_authorization(config)?)
.header(reqwest::header::CONTENT_TYPE, "application/yaml")
.body(content.to_string())
.send()
.await
.map_err(|error| format!("保存内核 YAML 配置失败: {error}"))?;
read_management_value(response).await.map(|_| ())
}
async fn fetch_codex_model_definitions(
config: &GuiConfigFile,
) -> Result<Vec<CodexModelDefinition>, String> {
let client = management_http_client()?;
let response = client
.get(management_endpoint(config, "model-definitions/codex")?)
.header("Authorization", management_authorization(config)?)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| format!("读取 Codex OAuth 模型定义失败: {error}"))?;
let payload = read_management_value(response).await?;
parse_codex_model_definitions(&payload)
}
fn resolved_thinking_alias_sources(
content: &str,
definitions: &[CodexModelDefinition],
available_models: &[AgentModelOption],
) -> Result<Vec<ResolvedThinkingAliasSource>, String> {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut sources = definitions
.iter()
.filter(|definition| {
!definition.reasoning_levels.is_empty()
&& thinking_alias_model_is_available(available_models, &definition.id)
})
.map(|definition| ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("codex-oauth:{}", definition.id),
model: definition.id.clone(),
display_name: definition.display_name.clone(),
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
protocol: "codex".to_string(),
},
location: ThinkingAliasSourceLocation::CodexOauth,
})
.collect::<Vec<_>>();
collect_config_thinking_alias_sources(
root,
"codex-api-key",
"Codex API",
"codex-api",
"codex",
available_models,
&mut sources,
)?;
collect_config_thinking_alias_sources(
root,
"openai-compatibility",
"OpenAI 兼容",
"openai-compatible",
"openai",
available_models,
&mut sources,
)?;
Ok(sources)
}
fn thinking_alias_model_is_available(available_models: &[AgentModelOption], model: &str) -> bool {
available_models
.iter()
.any(|available| available.name.eq_ignore_ascii_case(model))
}
fn collect_config_thinking_alias_sources(
root: &serde_norway::Mapping,
section: &'static str,
fallback_provider: &str,
kind: &str,
protocol: &str,
available_models: &[AgentModelOption],
sources: &mut Vec<ResolvedThinkingAliasSource>,
) -> Result<(), String> {
let Some(providers) = yaml_mapping_value(root, section) else {
return Ok(());
};
let providers = providers
.as_sequence()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for (provider_index, provider) in providers.iter().enumerate() {
let Some(provider) = provider.as_mapping() else {
continue;
};
if matches!(
yaml_mapping_value(provider, "disabled"),
Some(serde_norway::Value::Bool(true))
) {
continue;
}
let provider_name =
thinking_alias_provider_name(provider, fallback_provider, provider_index);
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
for (model_index, model) in models.iter().enumerate() {
let Some((upstream_model, client_model, display_name)) =
configured_model_identity(model)
else {
continue;
};
if !thinking_alias_model_is_available(available_models, &client_model) {
continue;
}
if client_model != upstream_model
&& find_thinking_alias_effort(root, &client_model, protocol).is_some()
{
continue;
}
sources.push(ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("{section}:{provider_index}:{model_index}"),
model: client_model,
display_name,
provider: provider_name.clone(),
kind: kind.to_string(),
protocol: protocol.to_string(),
},
location: ThinkingAliasSourceLocation::ConfigModel {
section,
provider_index,
model_index,
},
});
}
}
Ok(())
}
fn thinking_alias_provider_name(
provider: &serde_norway::Mapping,
fallback: &str,
index: usize,
) -> String {
yaml_mapping_value(provider, "name")
.or_else(|| yaml_mapping_value(provider, "base-url"))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{fallback} {}", index + 1))
}
fn configured_model_identity(
model: &serde_norway::Value,
) -> Option<(String, String, Option<String>)> {
if let Some(name) = model
.as_str()
.map(str::trim)
.filter(|name| !name.is_empty())
{
return Some((name.to_string(), name.to_string(), None));
}
let model = model.as_mapping()?;
let name = yaml_mapping_value(model, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())?;
let alias = yaml_mapping_value(model, "alias")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|alias| !alias.is_empty());
let display_name = yaml_mapping_value(model, "display-name")
.or_else(|| yaml_mapping_value(model, "display_name"))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Some((
name.to_string(),
alias.unwrap_or(name).to_string(),
display_name,
))
}
fn thinking_aliases_from_yaml(content: &str) -> Result<Vec<ThinkingAliasEntry>, String> {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
thinking_aliases_from_value(&document)
}
fn thinking_aliases_from_value(
document: &serde_norway::Value,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut entries = Vec::new();
if let Some(oauth_aliases) = yaml_mapping_value(root, "oauth-model-alias") {
let oauth_aliases = oauth_aliases
.as_mapping()
.ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?;
if let Some(codex_aliases) = yaml_mapping_value(oauth_aliases, "codex") {
let codex_aliases = codex_aliases
.as_sequence()
.ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?;
for entry in codex_aliases {
let Some(mapping) = entry.as_mapping() else {
continue;
};
if !matches!(
yaml_mapping_value(mapping, "fork"),
Some(serde_norway::Value::Bool(true))
) {
continue;
}
let Some(source_model) = yaml_mapping_value(mapping, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
let Some(alias) = yaml_mapping_value(mapping, "alias")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
entries.push(ThinkingAliasEntry {
source_model: source_model.to_string(),
alias: alias.to_string(),
effort: find_thinking_alias_effort(root, alias, "codex"),
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
});
}
}
}
collect_config_thinking_alias_entries(
root,
"codex-api-key",
"Codex API",
"codex-api",
"codex",
&mut entries,
)?;
collect_config_thinking_alias_entries(
root,
"openai-compatibility",
"OpenAI 兼容",
"openai-compatible",
"openai",
&mut entries,
)?;
entries.sort_by(|left, right| {
left.provider
.to_ascii_lowercase()
.cmp(&right.provider.to_ascii_lowercase())
.then_with(|| {
left.alias
.to_ascii_lowercase()
.cmp(&right.alias.to_ascii_lowercase())
})
});
Ok(entries)
}
fn collect_config_thinking_alias_entries(
root: &serde_norway::Mapping,
section: &str,
fallback_provider: &str,
kind: &str,
protocol: &str,
entries: &mut Vec<ThinkingAliasEntry>,
) -> Result<(), String> {
let Some(providers) = yaml_mapping_value(root, section) else {
return Ok(());
};
let providers = providers
.as_sequence()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for (provider_index, provider) in providers.iter().enumerate() {
let Some(provider) = provider.as_mapping() else {
continue;
};
let provider_name =
thinking_alias_provider_name(provider, fallback_provider, provider_index);
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
for model in models {
let Some((source_model, alias, _)) = configured_model_identity(model) else {
continue;
};
if source_model == alias {
continue;
}
let Some(effort) = find_thinking_alias_effort(root, &alias, protocol) else {
continue;
};
entries.push(ThinkingAliasEntry {
source_model,
alias,
effort: Some(effort),
provider: provider_name.clone(),
kind: kind.to_string(),
});
}
}
Ok(())
}
fn find_thinking_alias_effort(
root: &serde_norway::Mapping,
alias: &str,
protocol: &str,
) -> Option<String> {
let rules = nested_yaml_value(root, &["payload", "override"])?.as_sequence()?;
for rule in rules {
let Some(rule) = rule.as_mapping() else {
continue;
};
let effort = yaml_mapping_value(rule, "params")
.and_then(serde_norway::Value::as_mapping)
.and_then(|params| {
if protocol.eq_ignore_ascii_case("openai") {
yaml_mapping_value(params, "reasoning_effort")
.or_else(|| yaml_mapping_value(params, "reasoning.effort"))
} else {
yaml_mapping_value(params, "reasoning.effort")
}
})
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(effort) = effort else {
continue;
};
let Some(models) =
yaml_mapping_value(rule, "models").and_then(serde_norway::Value::as_sequence)
else {
continue;
};
if models
.iter()
.any(|model| thinking_payload_model_matches(model, alias, protocol))
{
return Some(effort.to_ascii_lowercase());
}
}
None
}
fn thinking_payload_model_matches(
model: &serde_norway::Value,
alias: &str,
protocol: &str,
) -> bool {
let Some(model) = model.as_mapping() else {
return false;
};
let name_matches = yaml_mapping_value(model, "name")
.and_then(serde_norway::Value::as_str)
.is_some_and(|name| name.trim().eq_ignore_ascii_case(alias));
let protocol_matches = yaml_mapping_value(model, "protocol")
.and_then(serde_norway::Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case(protocol));
name_matches && protocol_matches
}
fn add_thinking_alias_to_yaml(
content: &str,
source: &ResolvedThinkingAliasSource,
alias: &str,
effort: &str,
) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
if configured_model_alias_exists(root, alias) {
return Err(format!("别名模型 {alias} 已存在"));
}
match &source.location {
ThinkingAliasSourceLocation::CodexOauth => {
append_codex_oauth_thinking_alias(root, &source.source.model, alias)?;
}
ThinkingAliasSourceLocation::ConfigModel {
section,
provider_index,
model_index,
} => append_config_thinking_alias(
root,
section,
*provider_index,
*model_index,
&source.source.model,
alias,
effort,
)?,
}
remove_thinking_payload_model(root, alias)?;
let payload = root
.entry(yaml_key("payload"))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "payload 必须是 YAML 映射".to_string())?;
let override_rules = payload
.entry(yaml_key("override"))
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| "payload.override 必须是数组".to_string())?;
let mut model_mapping = serde_norway::Mapping::new();
model_mapping.insert(
yaml_key("name"),
serde_norway::Value::String(alias.to_string()),
);
model_mapping.insert(
yaml_key("protocol"),
serde_norway::Value::String(source.source.protocol.clone()),
);
let mut params_mapping = serde_norway::Mapping::new();
params_mapping.insert(
yaml_key(if source.source.protocol == "openai" {
"reasoning_effort"
} else {
"reasoning.effort"
}),
serde_norway::Value::String(effort.to_string()),
);
if source.source.protocol == "openai"
&& source
.source
.model
.to_ascii_lowercase()
.starts_with("deepseek")
{
params_mapping.insert(
yaml_key("thinking.type"),
serde_norway::Value::String("enabled".to_string()),
);
}
let mut rule_mapping = serde_norway::Mapping::new();
rule_mapping.insert(
yaml_key("models"),
serde_norway::Value::Sequence(vec![serde_norway::Value::Mapping(model_mapping)]),
);
rule_mapping.insert(
yaml_key("params"),
serde_norway::Value::Mapping(params_mapping),
);
override_rules.push(serde_norway::Value::Mapping(rule_mapping));
render_updated_core_yaml(&mut document, updated)
}
fn append_codex_oauth_thinking_alias(
root: &mut serde_norway::Mapping,
source_model: &str,
alias: &str,
) -> Result<(), String> {
let oauth_aliases = root
.entry(yaml_key("oauth-model-alias"))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?;
let codex_aliases = oauth_aliases
.entry(yaml_key("codex"))
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?;
let mut alias_mapping = serde_norway::Mapping::new();
alias_mapping.insert(
yaml_key("name"),
serde_norway::Value::String(source_model.to_string()),
);
alias_mapping.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
alias_mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true));
codex_aliases.push(serde_norway::Value::Mapping(alias_mapping));
Ok(())
}
fn append_config_thinking_alias(
root: &mut serde_norway::Mapping,
section: &str,
provider_index: usize,
model_index: usize,
expected_model: &str,
alias: &str,
effort: &str,
) -> Result<(), String> {
let providers = yaml_mapping_value_mut(root, section)
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section} 必须是数组"))?;
let provider = providers
.get_mut(provider_index)
.and_then(serde_norway::Value::as_mapping_mut)
.ok_or_else(|| "模型提供商已经变化,请刷新后重试".to_string())?;
let models = yaml_mapping_value_mut(provider, "models")
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
let source = models
.get(model_index)
.cloned()
.ok_or_else(|| "原模型已经变化,请刷新后重试".to_string())?;
let (_, current_model, _) =
configured_model_identity(&source).ok_or_else(|| "原模型配置格式无效".to_string())?;
if !current_model.eq_ignore_ascii_case(expected_model) {
return Err("原模型已经变化,请刷新后重试".to_string());
}
let mut alias_model = source.as_mapping().cloned().unwrap_or_else(|| {
let mut mapping = serde_norway::Mapping::new();
if let Some(name) = source.as_str() {
mapping.insert(
yaml_key("name"),
serde_norway::Value::String(name.to_string()),
);
}
mapping
});
alias_model.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
if let Some(display_name) = yaml_mapping_value(&alias_model, "display-name")
.and_then(serde_norway::Value::as_str)
.map(str::to_string)
{
alias_model.insert(
yaml_key("display-name"),
serde_norway::Value::String(format!("{display_name} ({effort})")),
);
}
let thinking = alias_model
.entry(yaml_key("thinking"))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "模型 thinking 必须是映射".to_string())?;
thinking.insert(
yaml_key("levels"),
serde_norway::Value::Sequence(vec![serde_norway::Value::String(effort.to_string())]),
);
models.push(serde_norway::Value::Mapping(alias_model));
Ok(())
}
fn remove_thinking_alias_from_yaml(content: &str, alias: &str) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut removed = false;
let mut remove_oauth_section = false;
if let Some(oauth_aliases) = yaml_mapping_value_mut(root, "oauth-model-alias") {
let oauth_aliases = oauth_aliases
.as_mapping_mut()
.ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?;
if let Some(codex_aliases) = yaml_mapping_value_mut(oauth_aliases, "codex") {
let codex_aliases = codex_aliases
.as_sequence_mut()
.ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?;
codex_aliases.retain(|entry| {
let matches = entry
.as_mapping()
.and_then(|mapping| yaml_mapping_value(mapping, "alias"))
.and_then(serde_norway::Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case(alias));
if matches {
removed = true;
}
!matches
});
if codex_aliases.is_empty() {
oauth_aliases.remove(yaml_key("codex"));
}
}
remove_oauth_section = oauth_aliases.is_empty();
}
removed |= remove_config_thinking_alias(root, "codex-api-key", "codex", alias)?;
removed |= remove_config_thinking_alias(root, "openai-compatibility", "openai", alias)?;
if !removed {
return Err(format!("别名模型 {alias} 不存在,请刷新后重试"));
}
if remove_oauth_section {
root.remove(yaml_key("oauth-model-alias"));
}
remove_thinking_payload_model(root, alias)?;
render_updated_core_yaml(&mut document, updated)
}
fn configured_model_alias_exists(root: &serde_norway::Mapping, alias: &str) -> bool {
let oauth_exists = yaml_mapping_value(root, "oauth-model-alias")
.and_then(serde_norway::Value::as_mapping)
.is_some_and(|channels| {
channels.values().any(|entries| {
entries.as_sequence().is_some_and(|entries| {
entries.iter().any(|entry| {
entry
.as_mapping()
.and_then(|mapping| yaml_mapping_value(mapping, "alias"))
.and_then(serde_norway::Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case(alias))
})
})
})
});
oauth_exists
|| ["codex-api-key", "openai-compatibility"]
.into_iter()
.filter_map(|section| yaml_mapping_value(root, section))
.filter_map(serde_norway::Value::as_sequence)
.flatten()
.filter_map(serde_norway::Value::as_mapping)
.filter_map(|provider| yaml_mapping_value(provider, "models"))
.filter_map(serde_norway::Value::as_sequence)
.flatten()
.filter_map(|model| configured_model_identity(model).map(|(_, alias, _)| alias))
.any(|value| value.eq_ignore_ascii_case(alias))
}
fn remove_config_thinking_alias(
root: &mut serde_norway::Mapping,
section: &str,
protocol: &str,
alias: &str,
) -> Result<bool, String> {
if find_thinking_alias_effort(root, alias, protocol).is_none() {
return Ok(false);
}
let Some(providers) = yaml_mapping_value_mut(root, section) else {
return Ok(false);
};
let providers = providers
.as_sequence_mut()
.ok_or_else(|| format!("{section} 必须是数组"))?;
let mut removed = false;
for provider in providers {
let Some(provider) = provider.as_mapping_mut() else {
continue;
};
let Some(models) = yaml_mapping_value_mut(provider, "models") else {
continue;
};
let models = models
.as_sequence_mut()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
models.retain(|model| {
let matches = configured_model_identity(model)
.map(|(source, model_alias, _)| {
source != model_alias && model_alias.eq_ignore_ascii_case(alias)
})
.unwrap_or(false);
removed |= matches;
!matches
});
}
Ok(removed)
}
fn remove_thinking_payload_model(
root: &mut serde_norway::Mapping,
alias: &str,
) -> Result<(), String> {
let mut remove_payload_section = false;
if let Some(payload) = yaml_mapping_value_mut(root, "payload") {
let payload = payload
.as_mapping_mut()
.ok_or_else(|| "payload 必须是 YAML 映射".to_string())?;
if let Some(override_rules) = yaml_mapping_value_mut(payload, "override") {
let override_rules = override_rules
.as_sequence_mut()
.ok_or_else(|| "payload.override 必须是数组".to_string())?;
let mut next_rules = Vec::with_capacity(override_rules.len());
for mut rule in std::mem::take(override_rules) {
let mut removed_from_rule = false;
let mut models_empty = false;
if let Some(rule_mapping) = rule.as_mapping_mut() {
let has_effort = yaml_mapping_value(rule_mapping, "params")
.and_then(serde_norway::Value::as_mapping)
.is_some_and(|params| {
yaml_mapping_value(params, "reasoning.effort").is_some()
|| yaml_mapping_value(params, "reasoning_effort").is_some()
});
if has_effort {
if let Some(models) = yaml_mapping_value_mut(rule_mapping, "models") {
let models = models
.as_sequence_mut()
.ok_or_else(|| "payload.override.models 必须是数组".to_string())?;
let before = models.len();
models.retain(|model| {
!thinking_payload_model_matches(model, alias, "codex")
&& !thinking_payload_model_matches(model, alias, "openai")
});
removed_from_rule = models.len() != before;
models_empty = models.is_empty();
}
}
}
if !(removed_from_rule && models_empty) {
next_rules.push(rule);
}
}
*override_rules = next_rules;
if override_rules.is_empty() {
payload.remove(yaml_key("override"));
}
}
remove_payload_section = payload.is_empty();
}
if remove_payload_section {
root.remove(yaml_key("payload"));
}
Ok(())
}
fn render_updated_core_yaml(
document: &mut yaml_serde_edit::YamlValue,
updated: serde_norway::Value,
) -> Result<String, String> {
document.set(updated);
let rendered = document.get_string();
serde_norway::from_str::<serde_norway::Value>(&rendered)
.map_err(|error| format!("验证更新后的内核配置失败: {error}"))?;
Ok(rendered)
}
fn management_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.map_err(|err| format!("创建管理 API 客户端失败: {err}"))
}
fn management_authorization(config: &GuiConfigFile) -> Result<String, String> {
let _ = config;
Ok(format!("Bearer {DEFAULT_MANAGEMENT_SECRET_KEY}"))
}
fn management_endpoint(config: &GuiConfigFile, path: &str) -> Result<String, String> {
if config.port == 0 {
return Err("内核端口无效".to_string());
}
let path = path.trim_start_matches('/');
Ok(format!(
"http://127.0.0.1:{}/v0/management/{path}",
config.port
))
}
fn normalize_management_oauth_provider(provider: &str) -> Result<String, String> {
let key = provider.trim().to_ascii_lowercase().replace('_', "-");
let key = match key.as_str() {
"claude" | "anthropic" => "anthropic".to_string(),
"anti-gravity" => "antigravity".to_string(),
"grok" | "x-ai" | "x.ai" => "xai".to_string(),
other => other.to_string(),
};
if key.is_empty()
|| !key
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err("无效的 OAuth 提供商".to_string());
}
Ok(key)
}
fn management_oauth_uses_webui_callback(provider_key: &str) -> bool {
matches!(provider_key, "codex" | "anthropic" | "antigravity" | "xai")
}
async fn read_management_json<T>(response: reqwest::Response) -> Result<T, String>
where
T: for<'de> Deserialize<'de>,
{
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
if !status.is_success() {
return Err(format_management_error(status.as_u16(), &text));
}
if text.trim().is_empty() {
return Err("管理 API 返回了空响应".to_string());
}
serde_json::from_str::<T>(&text).map_err(|err| {
format!(
"解析管理 API 响应失败: {err}; body={}",
truncate_for_error(&text)
)
})
}
async fn read_management_value(response: reqwest::Response) -> Result<serde_json::Value, String> {
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
if !status.is_success() {
return Err(format_management_error(status.as_u16(), &text));
}
if text.trim().is_empty() {
return Ok(serde_json::Value::Null);
}
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(value) => Ok(value),
Err(_) => Ok(serde_json::Value::String(text)),
}
}
async fn read_management_text(response: reqwest::Response) -> Result<String, String> {
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
if !status.is_success() {
return Err(format_management_error(status.as_u16(), &text));
}
Ok(text)
}
fn format_management_error(status: u16, body: &str) -> String {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(message) = value
.get("error")
.and_then(|item| item.as_str())
.or_else(|| value.get("message").and_then(|item| item.as_str()))
{
let message = message.trim();
if !message.is_empty() {
return format!("管理 API 错误 ({status}): {message}");
}
}
}
let body = body.trim();
if body.is_empty() {
format!("管理 API 错误 ({status})")
} else {
format!("管理 API 错误 ({status}): {}", truncate_for_error(body))
}
}
fn truncate_for_error(value: &str) -> String {
const LIMIT: usize = 240;
let trimmed = value.trim();
if trimmed.chars().count() <= LIMIT {
return trimmed.to_string();
}
let shortened: String = trimmed.chars().take(LIMIT).collect();
format!("{shortened}…")
}
fn open_external_url_inner(app: &tauri::AppHandle, url: &str) -> Result<(), String> {
let url = url.trim();
if url.is_empty() {
return Err("链接为空".to_string());
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("只允许打开 http/https 链接".to_string());
}
app.opener()
.open_url(url, None::<&str>)
.map_err(|err| format!("打开浏览器失败: {err}"))
}
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(())
}
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(())
}
fn validate_management_secret_key(secret_key: &str) -> Result<(), String> {
if secret_key != DEFAULT_MANAGEMENT_SECRET_KEY {
return Err("管理密钥统一固定为 123456".to_string());
}
Ok(())
}
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-")
}
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:")
}
fn validate_routing_strategy(strategy: &str) -> Result<(), String> {
if matches!(strategy, "round-robin" | "fill-first") {
return Ok(());
}
Err("路由策略只支持 round-robin 或 fill-first".to_string())
}
fn merge_core_config_yaml(
template: &str,
current: Option<&str>,
config: &GuiConfigFile,
) -> Result<String, String> {
let template_value = serde_norway::from_str::<serde_norway::Value>(template)
.map_err(|err| format!("解析内核配置模板失败: {err}"))?;
let mut merged = template_value.clone();
if let Some(current) = current {
let current = serde_norway::from_str::<serde_norway::Value>(current)
.map_err(|err| format!("解析现有内核配置失败: {err}"))?;
merge_yaml_values(&mut merged, current);
}
let merged = render_yaml_value_changes(template, &template_value, &merged)?;
apply_gui_managed_settings(&merged, config)
}
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)
})
}
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,
}
}
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 = if config.allow_lan {
"0.0.0.0"
} else {
"127.0.0.1"
};
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(())
}
fn apply_gui_managed_settings(content: &str, config: &GuiConfigFile) -> Result<String, String> {
let host = if config.allow_lan {
"0.0.0.0"
} else {
"127.0.0.1"
};
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,
"usage-statistics-enabled",
serde_norway::Value::Bool(true),
)?;
changed |= set_core_yaml_nested_value(
document,
"remote-management",
"secret-key",
serde_norway::Value::String(DEFAULT_MANAGEMENT_SECRET_KEY.to_string()),
)?;
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()),
)?;
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)
}
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)
));
}
Ok(())
}
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))]
fn replace_file_atomically(temporary_path: &Path, destination_path: &Path) -> io::Result<()> {
fs::rename(temporary_path, destination_path)
}
#[cfg(windows)]
fn replace_file_atomically(temporary_path: &Path, destination_path: &Path) -> io::Result<()> {
if !destination_path.exists() {
return fs::rename(temporary_path, destination_path);
}
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<_>>();
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(())
}
fn fixed_oauth_dir() -> Result<PathBuf, String> {
Ok(core_base_dir()?.join(OAUTH_DIR_NAME))
}
fn ensure_fixed_oauth_dir() -> Result<PathBuf, String> {
let directory = fixed_oauth_dir()?;
fs::create_dir_all(&directory)
.map_err(|err| format!("创建固定凭证目录失败 {}: {err}", path_to_string(&directory)))?;
Ok(directory)
}
fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
ensure_fixed_oauth_dir()?;
let config_path = gui_config_path()?;
let legacy_config_path = legacy_gui_config_path()?;
let (mut config, presence, mut changed) = if config_path.is_file() {
let content = fs::read_to_string(&config_path)
.map_err(|err| format!("读取 GUI 配置失败 {}: {err}", path_to_string(&config_path)))?;
let config = toml::from_str::<GuiConfigFile>(&content)
.map_err(|err| format!("解析 GUI 配置失败: {err}"))?;
let presence = toml::from_str::<GuiConfigPresence>(&content)
.map_err(|err| format!("解析 GUI 配置字段失败: {err}"))?;
(config, presence, false)
} else if legacy_config_path.is_file() {
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)
} else {
(GuiConfigFile::default(), GuiConfigPresence::default(), true)
};
let missing_core_settings = presence.api_keys.is_none()
|| presence.management_secret_key.is_none()
|| presence.plugins_enabled.is_none()
|| presence.routing_strategy.is_none();
if missing_core_settings {
if let Ok(core_settings) = read_installed_core_config_settings() {
if presence.api_keys.is_none() {
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.routing_strategy.is_none() {
config.routing_strategy = core_settings.routing_strategy;
}
}
changed = true;
}
if presence.auth_dir.is_none() {
changed = true;
}
if presence.locale.is_none() {
changed = true;
}
changed |= sanitize_gui_config(&mut config)?;
validate_gui_config(&config)?;
if changed {
write_gui_config(&config)?;
}
patch_core_auth_dir(&config.auth_dir)?;
patch_core_api_keys(&gui_api_key_values(&config.api_keys))?;
Ok(config)
}
#[cfg(test)]
fn apply_core_settings_to_gui_config(
config: &mut GuiConfigFile,
core_settings: &CoreConfigSettings,
) {
config.api_keys =
merge_core_api_keys_with_gui_metadata(&config.api_keys, &core_settings.api_keys, None);
config.management_secret_key = DEFAULT_MANAGEMENT_SECRET_KEY.to_string();
config.plugins_enabled = core_settings.plugins_enabled;
config.routing_strategy = core_settings.routing_strategy.clone();
}
fn built_in_api_key_entry() -> GuiApiKeyEntry {
GuiApiKeyEntry {
key: DEFAULT_API_KEY.to_string(),
remark: DEFAULT_API_KEY_REMARK.to_string(),
}
}
fn gui_api_key_values(entries: &[GuiApiKeyEntry]) -> Vec<String> {
entries.iter().map(|entry| entry.key.clone()).collect()
}
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![built_in_api_key_entry()];
for api_key in core_api_keys {
let api_key = api_key.trim();
if api_key.is_empty() || api_key == DEFAULT_API_KEY || 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
}
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 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 entry.key == DEFAULT_API_KEY {
entry.remark = DEFAULT_API_KEY_REMARK.to_string();
}
}
if config.api_keys != original_api_keys {
changed = true;
}
if config.management_secret_key != DEFAULT_MANAGEMENT_SECRET_KEY {
config.management_secret_key = DEFAULT_MANAGEMENT_SECRET_KEY.to_string();
changed = true;
}
let auth_dir = path_to_string(&fixed_oauth_dir()?);
if config.auth_dir != auth_dir {
config.auth_dir = auth_dir;
changed = true;
}
Ok(changed)
}
fn write_gui_config(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(|_| ())
}
fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> {
if config.port == 0 {
return Err("GUI 配置端口必须在 1 到 65535 之间".to_string());
}
for entry in &config.api_keys {
validate_core_api_key(&entry.key)?;
validate_api_key_remark(&entry.remark)?;
}
if config.routing_strategy.trim().is_empty() {
return Err("GUI 配置路由策略不能为空".to_string());
}
let expected_auth_dir = fixed_oauth_dir()?;
if Path::new(&config.auth_dir) != expected_auth_dir.as_path() {
return Err(format!(
"凭证目录固定为 {},不允许自定义",
path_to_string(&expected_auth_dir)
));
}
validate_management_secret_key(&config.management_secret_key)?;
Ok(())
}
fn gui_config_path() -> Result<PathBuf, String> {
Ok(core_base_dir()?.join(GUI_CONFIG_FILE))
}
fn legacy_gui_config_path() -> Result<PathBuf, String> {
Ok(core_base_dir()?.join(LEGACY_GUI_CONFIG_FILE))
}
fn stop_core_process_inner(process_state: &CoreProcessState) -> Result<(), String> {
if let Some(mut child) = process_state.take_child() {
terminate_child(&mut child)?;
process_state.clear_lifetime_guard();
return Ok(());
}
let install_dir = core_install_dir()?;
let binary_path = find_core_binary(&install_dir)
.ok_or_else(|| "未安装 CPA 内核,请先安装最新版".to_string())?;
let process_ids = find_core_process_ids(&binary_path);
if process_ids.is_empty() {
return Err("CPA 内核当前未运行".to_string());
}
for process_id in process_ids {
terminate_process_id(process_id)?;
}
Ok(())
}
fn core_install_dir() -> Result<PathBuf, String> {
Ok(core_base_dir()?.join("cpa-core"))
}
fn executable_dir() -> Result<PathBuf, String> {
let exe_path = env::current_exe().map_err(|err| format!("读取当前程序路径失败: {err}"))?;
exe_path
.parent()
.map(|path| path.to_path_buf())
.ok_or_else(|| format!("当前程序路径没有父目录: {}", path_to_string(&exe_path)))
}
fn macos_app_resources_dir(executable_dir: &Path) -> Option<PathBuf> {
if executable_dir.file_name().and_then(|name| name.to_str()) != Some("MacOS") {
return None;
}
let contents_dir = executable_dir.parent()?;
if contents_dir.file_name().and_then(|name| name.to_str()) != Some("Contents") {
return None;
}
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|extension| extension.to_str()) != Some("app") {
return None;
}
Some(contents_dir.join("Resources"))
}
fn core_base_dir() -> Result<PathBuf, String> {
let executable_dir = executable_dir()?;
#[cfg(target_os = "macos")]
if macos_app_resources_dir(&executable_dir).is_some() {
let home_dir = env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| "无法确定 macOS 用户目录".to_string())?;
return Ok(home_dir
.join("Library")
.join("Application Support")
.join("com.cpa.gui"));
}
Ok(executable_dir)
}
fn bundled_core_locations(base_dir: &Path, executable_dir: &Path) -> Vec<(PathBuf, PathBuf)> {
let mut locations = vec![(base_dir.join(CORE_VERSION_FILE), base_dir.join("cpa-core"))];
if let Some(resources_dir) = macos_app_resources_dir(executable_dir) {
locations.push((
resources_dir.join(CORE_VERSION_FILE),
resources_dir.join("cpa-core"),
));
}
if let Some(project_root) = source_project_root(executable_dir) {
if project_root != base_dir {
locations.push((
project_root.join(CORE_VERSION_FILE),
project_root.join("cpa-core"),
));
}
}
locations
}
fn bundled_core_archive() -> Result<Option<(BundledCoreInfo, PathBuf)>, String> {
let platform = current_core_platform()?;
let base_dir = core_base_dir()?;
let executable_dir = executable_dir()?;
let locations = bundled_core_locations(&base_dir, &executable_dir);
let configured_version = locations.iter().find_map(|(version_path, _)| {
fs::read_to_string(version_path)
.ok()
.map(|value| normalize_version(value.trim()))
.filter(|value| value != "v")
});
if let Some(version) = configured_version {
let asset_name = core_release_asset_name(&version, &platform);
for (_, archive_dir) in &locations {
let archive_path = archive_dir.join(&asset_name);
if !archive_path.is_file() {
continue;
}
let size_bytes = fs::metadata(&archive_path)
.map_err(|error| format!("读取内置内核信息失败: {error}"))?
.len();
return Ok(Some((
BundledCoreInfo {
version,
asset_name,
size_bytes,
},
archive_path,
)));
}
return Ok(None);
}
let suffix = format!(
"_{}_{}.{}",
platform.asset_os, platform.asset_arch, platform.archive_kind
);
let mut matches = Vec::new();
for (_, archive_dir) in &locations {
if !archive_dir.is_dir() {
continue;
}
for entry in fs::read_dir(archive_dir)
.map_err(|error| format!("读取内置内核目录失败: {error}"))?
.filter_map(Result::ok)
{
let name = entry.file_name().to_string_lossy().into_owned();
if !entry.path().is_file()
|| !name.starts_with("CLIProxyAPI_")
|| !name.ends_with(&suffix)
|| name.contains("_no-plugin")
{
continue;
}
let Some(version) = name
.strip_prefix("CLIProxyAPI_")
.and_then(|value| value.strip_suffix(&suffix))
else {
continue;
};
let version = normalize_version(version);
if matches.iter().any(|(existing, _, _)| existing == &version) {
continue;
}
matches.push((version, name, entry.path()));
}
}
matches.sort_by(|left, right| left.0.cmp(&right.0));
if matches.len() > 1 {
return Err(format!(
"发现多个匹配当前平台的内置内核,请在 {} 中指定发行版本",
CORE_VERSION_FILE
));
}
let Some((version, asset_name, archive_path)) = matches.pop() else {
return Ok(None);
};
let size_bytes = fs::metadata(&archive_path)
.map_err(|error| format!("读取内置内核信息失败: {error}"))?
.len();
Ok(Some((
BundledCoreInfo {
version,
asset_name,
size_bytes,
},
archive_path,
)))
}
fn source_project_root(start: &Path) -> Option<PathBuf> {
start.ancestors().find_map(|directory| {
(directory.join("package.json").is_file() && directory.join("src-tauri").is_dir())
.then(|| directory.to_path_buf())
})
}
fn preserve_bundled_core_assets(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
if !source_dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(source_dir)
.map_err(|error| format!("读取内置内核文件失败: {error}"))?
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let is_archive = name.starts_with("CLIProxyAPI_")
&& (name.ends_with(".tar.gz") || name.ends_with(".zip"))
&& !name.contains("_no-plugin");
if !is_archive && name != CORE_CHECKSUMS_FILE {
continue;
}
fs::copy(&path, target_dir.join(&name))
.map_err(|error| format!("保留内置内核文件 {name} 失败: {error}"))?;
}
Ok(())
}
fn preserve_selected_bundled_core_asset(
archive_path: &Path,
target_dir: &Path,
) -> Result<(), String> {
let archive_name = archive_path
.file_name()
.ok_or_else(|| "内置内核压缩包文件名无效".to_string())?;
fs::copy(archive_path, target_dir.join(archive_name))
.map_err(|error| format!("保留所选内置内核压缩包失败: {error}"))?;
if let Some(source_dir) = archive_path.parent() {
let checksums = source_dir.join(CORE_CHECKSUMS_FILE);
if checksums.is_file() {
fs::copy(&checksums, target_dir.join(CORE_CHECKSUMS_FILE))
.map_err(|error| format!("保留内置内核校验文件失败: {error}"))?;
}
}
Ok(())
}
fn preserve_core_runtime_files(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
if !source_dir.is_dir() {
return Ok(());
}
let source = source_dir.join(CORE_CONFIG_FILE);
if source.is_file() {
fs::copy(&source, target_dir.join(CORE_CONFIG_FILE))
.map_err(|error| format!("保留内核配置文件失败: {error}"))?;
}
Ok(())
}
fn validate_bundled_core_checksum(archive_path: &Path) -> Result<(), String> {
let Some(directory) = archive_path.parent() else {
return Err("内置内核压缩包没有父目录".to_string());
};
let checksums_path = directory.join(CORE_CHECKSUMS_FILE);
if !checksums_path.is_file() {
return Ok(());
}
let archive_name = archive_path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| "内置内核压缩包文件名无效".to_string())?;
let checksums = fs::read_to_string(&checksums_path)
.map_err(|error| format!("读取内置内核校验文件失败: {error}"))?;
let expected = checksums.lines().find_map(|line| {
let mut fields = line.split_whitespace();
let digest = fields.next()?;
let name = fields.next()?.trim_start_matches('*');
(name == archive_name && digest.len() == 64).then(|| digest.to_ascii_lowercase())
});
let Some(expected) = expected else {
return Err(format!("校验文件中没有 {archive_name} 的 SHA-256"));
};
let actual = sha256_file(archive_path)?;
if actual != expected {
return Err("内置内核压缩包 SHA-256 校验失败".to_string());
}
Ok(())
}
fn sha256_file(path: &Path) -> Result<String, String> {
let mut file = File::open(path).map_err(|error| format!("打开校验文件失败: {error}"))?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|error| format!("读取校验文件失败: {error}"))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn read_core_metadata(install_dir: &Path) -> Option<CoreMetadata> {
let metadata_path = install_dir.join(CORE_METADATA_FILE);
let content = fs::read_to_string(metadata_path).ok()?;
serde_json::from_str(&content).ok()
}
fn write_core_metadata(install_dir: &Path, metadata: &CoreMetadata) -> Result<(), String> {
let metadata_path = install_dir.join(CORE_METADATA_FILE);
let content = serde_json::to_string_pretty(metadata)
.map_err(|err| format!("生成内核元数据失败: {err}"))?;
fs::write(metadata_path, content).map_err(|err| format!("写入内核元数据失败: {err}"))
}
fn validate_downloaded_asset(
asset: &GithubAsset,
downloaded: &DownloadedArchive,
) -> Result<(), String> {
validate_download_metadata(
downloaded.size,
asset.size,
&downloaded.sha256,
asset.digest.as_deref(),
)
}
fn validate_download_metadata(
downloaded: u64,
expected_total: Option<u64>,
sha256: &str,
expected_digest: Option<&str>,
) -> Result<(), String> {
if let Some(expected_total) = expected_total {
if downloaded != expected_total {
return Err(format!(
"下载大小校验失败: 实际 {downloaded} 字节,期望 {expected_total} 字节"
));
}
}
if let Some(expected_digest) = expected_digest {
let expected = expected_digest
.strip_prefix("sha256:")
.unwrap_or(expected_digest)
.to_ascii_lowercase();
if !expected.is_empty() && sha256 != expected {
return Err("下载文件 SHA-256 校验失败".to_string());
}
}
Ok(())
}
fn cleanup_core_work_dirs() -> Result<(), String> {
let base_dir = core_base_dir()?;
let mut last_error = None;
for name in ["cpa-core.staging", "cpa-core.download"] {
let path = base_dir.join(name);
if path.exists() {
if let Err(err) = fs::remove_dir_all(&path) {
last_error = Some(format!("清理临时目录失败 {}: {err}", path_to_string(&path)));
}
}
}
if let Some(error) = last_error {
Err(error)
} else {
Ok(())
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn normalize_version(version: &str) -> String {
let version = version.trim();
if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
}
}
fn is_core_running(binary_path: &Path) -> bool {
!find_core_process_ids(binary_path).is_empty()
}
fn find_core_process_ids(binary_path: &Path) -> Vec<u32> {
#[cfg(target_os = "linux")]
{
find_core_process_ids_linux(binary_path)
}
#[cfg(not(target_os = "linux"))]
{
let _ = binary_path;
find_core_process_ids_by_name()
}
}
#[cfg(target_os = "linux")]
fn find_core_process_ids_linux(binary_path: &Path) -> Vec<u32> {
let Ok(expected) = fs::canonicalize(binary_path) else {
return Vec::new();
};
let output = Command::new("pgrep")
.args(["-x", core_binary_name()])
.output();
let Ok(output) = output else {
return Vec::new();
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.filter(|pid| {
fs::read_link(format!("/proc/{pid}/exe"))
.ok()
.and_then(|path| fs::canonicalize(path).ok())
.map(|path| path == expected)
.unwrap_or(false)
})
.collect()
}
#[cfg(all(not(target_os = "linux"), target_os = "windows"))]
fn find_core_process_ids_by_name() -> Vec<u32> {
let image_name = core_binary_name();
let filter = format!("IMAGENAME eq {image_name}");
let mut command = Command::new("tasklist");
command.args(["/FI", &filter, "/FO", "CSV", "/NH"]);
configure_background_command(&mut command);
let output = command.output();
let Ok(output) = output else {
return Vec::new();
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let columns = line
.trim()
.trim_matches('"')
.split("\",\"")
.collect::<Vec<_>>();
let name = columns.first()?;
let pid = columns.get(1)?;
name.eq_ignore_ascii_case(image_name)
.then(|| pid.parse::<u32>().ok())
.flatten()
})
.collect()
}
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
fn find_core_process_ids_by_name() -> Vec<u32> {
Command::new("pgrep")
.args(["-x", core_binary_name()])
.output()
.ok()
.map(|output| {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect()
})
.unwrap_or_default()
}
fn shutdown_managed_core(process_state: &CoreProcessState, gui_config_state: &GuiConfigState) {
let was_running = process_state.managed_pid().is_some();
let _ = gui_config_state.set_run_on_startup(was_running);
if let Some(mut child) = process_state.take_child() {
let _ = terminate_child(&mut child);
}
process_state.clear_lifetime_guard();
}
#[cfg(windows)]
fn attach_child_to_windows_job(child: &Child) -> Result<isize, String> {
use std::{mem, os::windows::io::AsRawHandle, ptr};
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE},
System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
};
unsafe {
let job = CreateJobObjectW(ptr::null(), ptr::null());
if job.is_null() {
return Err(format!(
"创建 CPA 内核进程作业失败: {}",
io::Error::last_os_error()
));
}
let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = mem::zeroed();
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&information as *const _ as *const _,
mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if configured == 0 {
let error = io::Error::last_os_error();
CloseHandle(job);
return Err(format!("配置 CPA 内核进程作业失败: {error}"));
}
let process_handle = child.as_raw_handle() as HANDLE;
if AssignProcessToJobObject(job, process_handle) == 0 {
let error = io::Error::last_os_error();
CloseHandle(job);
return Err(format!("托管 CPA 内核子进程失败: {error}"));
}
Ok(job as isize)
}
}
#[cfg(windows)]
fn close_windows_handle(handle: isize) {
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
unsafe {
CloseHandle(handle as HANDLE);
}
}
fn terminate_child(child: &mut Child) -> Result<(), String> {
#[cfg(windows)]
{
child
.kill()
.map_err(|err| format!("关闭 CPA 内核进程失败: {err}"))?;
child
.wait()
.map_err(|err| format!("等待 CPA 内核进程退出失败: {err}"))?;
return Ok(());
}
#[cfg(not(windows))]
{
let process_id = child.id();
send_process_signal(process_id, "TERM")?;
for _ in 0..20 {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => thread::sleep(Duration::from_millis(100)),
Err(err) => return Err(format!("检查 CPA 内核进程状态失败: {err}")),
}
}
child
.kill()
.map_err(|err| format!("强制关闭 CPA 内核进程失败: {err}"))?;
child
.wait()
.map_err(|err| format!("等待 CPA 内核进程退出失败: {err}"))?;
Ok(())
}
}
fn terminate_process_id(process_id: u32) -> Result<(), String> {
#[cfg(windows)]
{
let process_id = process_id.to_string();
let mut command = Command::new("taskkill");
command
.args(["/PID", &process_id, "/T", "/F"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
let status = command
.status()
.map_err(|err| format!("关闭 CPA 内核进程失败: {err}"))?;
if status.success() {
return Ok(());
}
return Err(format!("关闭 CPA 内核进程失败: PID {process_id}"));
}
#[cfg(not(windows))]
{
send_process_signal(process_id, "TERM")?;
for _ in 0..20 {
if !is_process_alive(process_id) {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
send_process_signal(process_id, "KILL")
}
}
#[cfg(not(windows))]
fn send_process_signal(process_id: u32, signal: &str) -> Result<(), String> {
let status = Command::new("kill")
.args([format!("-{signal}"), process_id.to_string()])
.status()
.map_err(|err| format!("发送进程信号失败: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("发送进程信号失败: PID {process_id}"))
}
}
#[cfg(not(windows))]
fn is_process_alive(process_id: u32) -> bool {
let process_id = process_id.to_string();
Command::new("kill")
.args(["-0", &process_id])
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn reset_dir(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_dir_all(path)
.map_err(|err| format!("清理目录失败 {}: {err}", path_to_string(path)))?;
}
fs::create_dir_all(path).map_err(|err| format!("创建目录失败 {}: {err}", path_to_string(path)))
}
fn replace_install_dir(
install_dir: &Path,
staging_dir: &Path,
backup_dir: &Path,
) -> Result<(), String> {
if backup_dir.exists() {
fs::remove_dir_all(backup_dir).map_err(|err| format!("清理备份目录失败: {err}"))?;
}
if install_dir.exists() {
fs::rename(install_dir, backup_dir)
.map_err(|err| format!("备份旧内核目录失败,请确认 CPA 内核未运行: {err}"))?;
}
if let Err(err) = fs::rename(staging_dir, install_dir) {
if backup_dir.exists() {
let _ = fs::rename(backup_dir, install_dir);
}
return Err(format!("切换新内核目录失败: {err}"));
}
if backup_dir.exists() {
fs::remove_dir_all(backup_dir).map_err(|err| format!("删除旧内核备份目录失败: {err}"))?;
}
Ok(())
}
fn extract_tar_gz(archive_path: &Path, install_dir: &Path) -> Result<(), String> {
let archive_file =
File::open(archive_path).map_err(|err| format!("打开 tar.gz 失败: {err}"))?;
let decoder = GzDecoder::new(archive_file);
let mut archive = Archive::new(decoder);
let entries = archive
.entries()
.map_err(|err| format!("读取 tar.gz 条目失败: {err}"))?;
for entry in entries {
let mut entry = entry.map_err(|err| format!("读取 tar.gz 条目失败: {err}"))?;
let entry_path = entry
.path()
.map_err(|err| format!("读取 tar.gz 条目路径失败: {err}"))?;
let out_path = checked_archive_path(install_dir, entry_path.as_ref())?;
let entry_type = entry.header().entry_type();
if entry_type.is_dir() {
fs::create_dir_all(&out_path).map_err(|err| format!("创建目录失败: {err}"))?;
} else if entry_type.is_file() {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| format!("创建目录失败: {err}"))?;
}
entry
.unpack(&out_path)
.map_err(|err| format!("解压 tar.gz 文件失败: {err}"))?;
} else {
return Err(format!(
"tar.gz 包含不支持的条目类型: {}",
path_to_string(&out_path)
));
}
}
Ok(())
}
fn extract_zip(archive_path: &Path, install_dir: &Path) -> Result<(), String> {
let archive_file = File::open(archive_path).map_err(|err| format!("打开 zip 失败: {err}"))?;
let mut archive =
ZipArchive::new(archive_file).map_err(|err| format!("读取 zip 失败: {err}"))?;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|err| format!("读取 zip 条目失败: {err}"))?;
let enclosed_name = file
.enclosed_name()
.ok_or_else(|| format!("zip 条目路径不安全: {}", file.name()))?;
let out_path = checked_archive_path(install_dir, &enclosed_name)?;
if is_zip_symlink(&file) {
return Err(format!("zip 包含不支持的符号链接条目: {}", file.name()));
}
if file.is_dir() {
fs::create_dir_all(&out_path).map_err(|err| format!("创建目录失败: {err}"))?;
continue;
}
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| format!("创建目录失败: {err}"))?;
}
let mut out_file = File::create(&out_path).map_err(|err| format!("创建文件失败: {err}"))?;
io::copy(&mut file, &mut out_file).map_err(|err| format!("写入文件失败: {err}"))?;
#[cfg(unix)]
if let Some(mode) = file.unix_mode() {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))
.map_err(|err| format!("设置文件权限失败: {err}"))?;
}
}
Ok(())
}
fn checked_archive_path(base_dir: &Path, entry_path: &Path) -> Result<PathBuf, String> {
if entry_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(format!(
"压缩包条目路径不安全: {}",
path_to_string(entry_path)
));
}
Ok(base_dir.join(entry_path))
}
fn is_zip_symlink(file: &zip::read::ZipFile<'_>) -> bool {
file.unix_mode()
.map(|mode| mode & 0o170000 == 0o120000)
.unwrap_or(false)
}
fn find_core_binary(install_dir: &Path) -> Option<PathBuf> {
let binary_path = install_dir.join(core_binary_name());
if binary_path.is_file() {
return Some(binary_path);
}
let mut dirs = vec![install_dir.to_path_buf()];
while let Some(dir) = dirs.pop() {
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if path
.file_name()
.and_then(|file_name| file_name.to_str())
.map(|file_name| file_name == core_binary_name())
.unwrap_or(false)
{
return Some(path);
}
}
}
None
}
fn core_binary_name() -> &'static str {
if env::consts::OS == "windows" {
"cli-proxy-api.exe"
} else {
"cli-proxy-api"
}
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
#[cfg(target_os = "macos")]
const MACOS_TRAY_ID: &str = "macos-tray";
#[cfg(target_os = "macos")]
#[derive(Default)]
struct MacosTrayClickState {
last_click: Option<Instant>,
sequence: u64,
}
#[cfg(target_os = "macos")]
fn set_macos_dock_visible(visible: bool) -> bool {
let Some(mtm) = MainThreadMarker::new() else {
eprintln!("更新 Dock 图标状态必须在主线程执行");
return false;
};
let policy = if visible {
NSApplicationActivationPolicy::Regular
} else {
NSApplicationActivationPolicy::Accessory
};
if !NSApplication::sharedApplication(mtm).setActivationPolicy(policy) {
eprintln!("更新 Dock 图标状态失败");
return false;
}
true
}
#[cfg(target_os = "macos")]
fn show_main_window_on_main_thread(app_handle: &tauri::AppHandle) {
let Some(window) = app_handle.get_webview_window("main") else {
return;
};
if !set_macos_dock_visible(true) {
return;
}
if let Err(error) = window.show() {
eprintln!("显示主窗口失败: {error}");
set_macos_dock_visible(false);
return;
}
if window.is_minimized().unwrap_or(false) {
if let Err(error) = window.unminimize() {
eprintln!("恢复主窗口失败: {error}");
}
}
if let Err(error) = window.set_focus() {
eprintln!("聚焦主窗口失败: {error}");
}
}
#[cfg(target_os = "macos")]
fn show_main_window(app_handle: &tauri::AppHandle) {
if MainThreadMarker::new().is_some() {
show_main_window_on_main_thread(app_handle);
return;
}
let app_handle = app_handle.clone();
if let Err(error) = app_handle.clone().run_on_main_thread(move || {
show_main_window_on_main_thread(&app_handle);
}) {
eprintln!("调度主窗口显示失败: {error}");
}
}
#[cfg(target_os = "macos")]
fn show_macos_tray_menu<R: tauri::Runtime>(tray: &TrayIcon<R>) {
let result = tray.with_inner_tray_icon(|tray_icon| {
let Some(status_item) = tray_icon.ns_status_item() else {
return;
};
let mtm = MainThreadMarker::new().expect("tray menu must be shown on the main thread");
if let Some(menu) = status_item.menu(mtm) {
#[allow(deprecated)]
status_item.popUpStatusItemMenu(&menu);
}
});
if let Err(error) = result {
eprintln!("显示托盘菜单失败: {error}");
}
}
#[cfg(target_os = "macos")]
fn setup_macos_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
let locale = app
.state::<GuiConfigState>()
.snapshot()
.map(|config| config.locale)
.unwrap_or_else(|_| "zh-CN".to_string());
let open_main_window = MenuItem::with_id(
app,
"open-main-window",
locale_text(&locale, "打开主界面", "Open Main Window"),
true,
None::<&str>,
)?;
let quit = MenuItem::with_id(
app,
"quit",
locale_text(&locale, "退出", "Quit"),
true,
None::<&str>,
)?;
let menu = Menu::with_items(app, &[&open_main_window, &quit])?;
let click_state = Arc::new(Mutex::new(MacosTrayClickState::default()));
let double_click_interval = Duration::from_secs_f64(NSEvent::doubleClickInterval());
TrayIconBuilder::with_id(MACOS_TRAY_ID)
.icon(
app.default_window_icon()
.cloned()
.expect("application icon is required for the tray"),
)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app_handle, event| match event.id().as_ref() {
"open-main-window" => show_main_window(app_handle),
"quit" => app_handle.exit(0),
_ => {}
})
.on_tray_icon_event(move |tray, event| {
if !matches!(
event,
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
}
) {
return;
}
let now = Instant::now();
let Ok(mut state) = click_state.lock() else {
eprintln!("读取托盘点击状态失败");
return;
};
state.sequence += 1;
let sequence = state.sequence;
if state
.last_click
.is_some_and(|last_click| now.duration_since(last_click) <= double_click_interval)
{
state.last_click = None;
drop(state);
show_main_window(tray.app_handle());
return;
}
state.last_click = Some(now);
drop(state);
let app_handle = tray.app_handle().clone();
let click_state = Arc::clone(&click_state);
let tray_id = tray.id().clone();
thread::spawn(move || {
thread::sleep(double_click_interval);
let should_show_menu = match click_state.lock() {
Ok(mut state) if state.sequence == sequence => {
state.last_click = None;
true
}
Ok(_) => false,
Err(_) => {
eprintln!("读取托盘点击状态失败");
false
}
};
if !should_show_menu {
return;
}
if let Some(tray) = app_handle.tray_by_id(&tray_id) {
show_macos_tray_menu(&tray);
}
});
})
.build(app)?;
Ok(())
}
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_ID: &str = "windows-tray";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_OPEN_MENU_ID: &str = "windows-tray-open-main-window";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_STATUS_MENU_ID: &str = "windows-tray-core-status";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_TOGGLE_CORE_MENU_ID: &str = "windows-tray-toggle-core";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_RESTART_CORE_MENU_ID: &str = "windows-tray-restart-core";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_QUIT_MENU_ID: &str = "windows-tray-quit";
#[cfg(target_os = "windows")]
#[derive(Clone, Copy)]
enum WindowsTrayCoreAction {
Toggle,
Restart,
}
#[cfg(target_os = "windows")]
struct WindowsTrayPresentation {
status_text: String,
toggle_text: String,
toggle_enabled: bool,
restart_enabled: bool,
tooltip: String,
}
#[cfg(target_os = "windows")]
fn windows_tray_presentation(
status: &CoreStatus,
busy: bool,
locale: &str,
) -> WindowsTrayPresentation {
let status_text = if busy {
locale_text(locale, "内核状态:处理中", "Core status: Working")
} else if !status.installed {
locale_text(locale, "内核状态:未安装", "Core status: Not installed")
} else if status.running {
locale_text(locale, "内核状态:运行中", "Core status: Running")
} else {
locale_text(locale, "内核状态:已停止", "Core status: Stopped")
};
let toggle_text = if busy {
locale_text(locale, "处理中...", "Working...")
} else if status.running {
locale_text(locale, "停止内核", "Stop Core")
} else {
locale_text(locale, "启动内核", "Start Core")
};
let tooltip = if busy {
locale_text(
locale,
"EasyCLIProxyAPI · 内核处理中",
"EasyCLIProxyAPI · Core working",
)
} else if !status.installed {
locale_text(
locale,
"EasyCLIProxyAPI · 内核未安装",
"EasyCLIProxyAPI · Core not installed",
)
} else if status.running {
locale_text(
locale,
"EasyCLIProxyAPI · 内核运行中",
"EasyCLIProxyAPI · Core running",
)
} else {
locale_text(
locale,
"EasyCLIProxyAPI · 内核已停止",
"EasyCLIProxyAPI · Core stopped",
)
};
WindowsTrayPresentation {
status_text: status_text.to_string(),
toggle_text: toggle_text.to_string(),
toggle_enabled: status.installed && !busy,
restart_enabled: status.installed && status.running && !busy,
tooltip: tooltip.to_string(),
}
}
#[cfg(target_os = "windows")]
struct WindowsTrayState {
open_main_window: MenuItem<tauri::Wry>,
status_item: MenuItem<tauri::Wry>,
toggle_core_item: MenuItem<tauri::Wry>,
restart_core_item: MenuItem<tauri::Wry>,
quit_item: MenuItem<tauri::Wry>,
locale: Mutex<String>,
busy: AtomicBool,
}
#[cfg(target_os = "windows")]
impl WindowsTrayState {
fn locale(&self) -> String {
self.locale
.lock()
.map(|locale| locale.clone())
.unwrap_or_else(|_| "zh-CN".to_string())
}
fn set_locale(&self, locale: &str) {
let normalized = normalize_app_locale(locale);
if let Ok(mut current) = self.locale.lock() {
*current = normalized.to_string();
}
let labels = [
(
&self.open_main_window,
locale_text(normalized, "打开主界面", "Open Main Window"),
),
(
&self.restart_core_item,
locale_text(normalized, "重启内核", "Restart Core"),
),
(&self.quit_item, locale_text(normalized, "退出", "Quit")),
];
for (item, label) in labels {
if let Err(error) = item.set_text(label) {
eprintln!("更新 Windows 托盘语言失败: {error}");
}
}
}
fn begin_action(&self) -> bool {
if self
.busy
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return false;
}
let locale = self.locale();
if let Err(error) = self.status_item.set_text(locale_text(
&locale,
"内核状态:处理中",
"Core status: Working",
)) {
eprintln!("更新 Windows 托盘内核状态失败: {error}");
}
if let Err(error) =
self.toggle_core_item
.set_text(locale_text(&locale, "处理中...", "Working..."))
{
eprintln!("更新 Windows 托盘操作文本失败: {error}");
}
if let Err(error) = self.toggle_core_item.set_enabled(false) {
eprintln!("更新 Windows 托盘操作状态失败: {error}");
}
if let Err(error) = self.restart_core_item.set_enabled(false) {
eprintln!("更新 Windows 托盘重启状态失败: {error}");
}
true
}
fn finish_action(&self) {
self.busy.store(false, Ordering::Release);
}
fn update(&self, status: &CoreStatus) -> String {
let presentation =
windows_tray_presentation(status, self.busy.load(Ordering::Acquire), &self.locale());
if let Err(error) = self.status_item.set_text(presentation.status_text.clone()) {
eprintln!("更新 Windows 托盘内核状态失败: {error}");
}
if let Err(error) = self
.toggle_core_item
.set_text(presentation.toggle_text.clone())
{
eprintln!("更新 Windows 托盘操作文本失败: {error}");
}
if let Err(error) = self
.toggle_core_item
.set_enabled(presentation.toggle_enabled)
{
eprintln!("更新 Windows 托盘操作状态失败: {error}");
}
if let Err(error) = self
.restart_core_item
.set_enabled(presentation.restart_enabled)
{
eprintln!("更新 Windows 托盘重启状态失败: {error}");
}
presentation.tooltip
}
fn show_error(&self, error: &str) {
let mut summary = error.chars().take(48).collect::<String>();
if error.chars().count() > 48 {
summary.push('…');
}
let text = match normalize_app_locale(&self.locale()) {
"en" => format!("Operation failed: {summary}"),
"zh-TW" => format!("操作失敗:{summary}"),
"ja" => format!("操作に失敗しました:{summary}"),
_ => format!("操作失败:{summary}"),
};
if let Err(update_error) = self.status_item.set_text(text) {
eprintln!("更新 Windows 托盘错误状态失败: {update_error}");
}
}
}
#[cfg(target_os = "windows")]
fn show_windows_main_window(app_handle: &tauri::AppHandle) {
let Some(window) = app_handle.get_webview_window("main") else {
return;
};
if let Err(error) = window.show() {
eprintln!("显示主窗口失败: {error}");
return;
}
if window.is_minimized().unwrap_or(false) {
if let Err(error) = window.unminimize() {
eprintln!("恢复主窗口失败: {error}");
}
}
if let Err(error) = window.set_focus() {
eprintln!("聚焦主窗口失败: {error}");
}
}
#[cfg(target_os = "windows")]
fn update_windows_tray_status(app_handle: &tauri::AppHandle, status: &CoreStatus) {
let tooltip = app_handle
.try_state::<WindowsTrayState>()
.map(|tray_state| tray_state.update(status))
.unwrap_or_else(|| windows_tray_presentation(status, false, "zh-CN").tooltip);
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(error) = tray.set_tooltip(Some(&tooltip)) {
eprintln!("更新 Windows 托盘提示失败: {error}");
}
}
}
#[cfg(target_os = "windows")]
fn update_windows_tray_locale(app_handle: &tauri::AppHandle, locale: &str, status: &CoreStatus) {
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
tray_state.set_locale(locale);
}
update_windows_tray_status(app_handle, status);
}
#[cfg(target_os = "windows")]
fn show_windows_tray_action_error(app_handle: &tauri::AppHandle, error: &str) {
eprintln!("Windows 托盘内核操作失败: {error}");
let locale = app_handle
.try_state::<WindowsTrayState>()
.map(|state| state.locale())
.unwrap_or_else(|| "zh-CN".to_string());
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
tray_state.show_error(error);
}
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(update_error) = tray.set_tooltip(Some(locale_text(
&locale,
"EasyCLIProxyAPI · 内核操作失败",
"EasyCLIProxyAPI · Core operation failed",
))) {
eprintln!("更新 Windows 托盘错误提示失败: {update_error}");
}
}
}
#[cfg(target_os = "windows")]
fn run_windows_tray_core_action(app_handle: &tauri::AppHandle, action: WindowsTrayCoreAction) {
let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() else {
return;
};
if !tray_state.begin_action() {
return;
}
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(error) = tray.set_tooltip(Some(locale_text(
&tray_state.locale(),
"EasyCLIProxyAPI · 内核处理中",
"EasyCLIProxyAPI · Core working",
))) {
eprintln!("更新 Windows 托盘处理中提示失败: {error}");
}
}
let app_handle = app_handle.clone();
tauri::async_runtime::spawn_blocking(move || {
let process_state = app_handle.state::<CoreProcessState>();
let gui_config_state = app_handle.state::<GuiConfigState>();
let result = (|| -> Result<CoreStatus, String> {
match action {
WindowsTrayCoreAction::Toggle => {
let config = gui_config_state.snapshot()?;
let status =
current_core_status(Some(process_state.inner()), Some(config.port))?;
if status.running {
stop_core_process_with_state(
process_state.inner(),
gui_config_state.inner(),
)
} else {
start_core_process_with_state(
process_state.inner(),
gui_config_state.inner(),
)
}
}
WindowsTrayCoreAction::Restart => {
restart_core_process_with_state(process_state.inner(), gui_config_state.inner())
}
}
})();
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
tray_state.finish_action();
}
match result {
Ok(status) => emit_core_status(&app_handle, &status),
Err(error) => {
if let Ok(config) = gui_config_state.snapshot() {
if let Ok(status) =
current_core_status(Some(process_state.inner()), Some(config.port))
{
emit_core_status(&app_handle, &status);
}
}
show_windows_tray_action_error(&app_handle, &error);
}
}
});
}
#[cfg(target_os = "windows")]
fn setup_windows_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
let locale = app
.state::<GuiConfigState>()
.snapshot()
.map(|config| config.locale)
.unwrap_or_else(|_| "zh-CN".to_string());
let open_main_window = MenuItem::with_id(
app,
WINDOWS_TRAY_OPEN_MENU_ID,
locale_text(&locale, "打开主界面", "Open Main Window"),
true,
None::<&str>,
)?;
let status_item = MenuItem::with_id(
app,
WINDOWS_TRAY_STATUS_MENU_ID,
locale_text(&locale, "内核状态:正在检查", "Core status: Checking"),
false,
None::<&str>,
)?;
let toggle_core_item = MenuItem::with_id(
app,
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID,
locale_text(&locale, "启动内核", "Start Core"),
false,
None::<&str>,
)?;
let restart_core_item = MenuItem::with_id(
app,
WINDOWS_TRAY_RESTART_CORE_MENU_ID,
locale_text(&locale, "重启内核", "Restart Core"),
false,
None::<&str>,
)?;
let quit = MenuItem::with_id(
app,
WINDOWS_TRAY_QUIT_MENU_ID,
locale_text(&locale, "退出", "Quit"),
true,
None::<&str>,
)?;
let separator_one = PredefinedMenuItem::separator(app)?;
let separator_two = PredefinedMenuItem::separator(app)?;
let menu = Menu::with_items(
app,
&[
&open_main_window,
&separator_one,
&status_item,
&toggle_core_item,
&restart_core_item,
&separator_two,
&quit,
],
)?;
TrayIconBuilder::with_id(WINDOWS_TRAY_ID)
.icon(
app.default_window_icon()
.cloned()
.expect("application icon is required for the tray"),
)
.tooltip("EasyCLIProxyAPI")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app_handle, event| match event.id().as_ref() {
WINDOWS_TRAY_OPEN_MENU_ID => show_windows_main_window(app_handle),
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID => {
run_windows_tray_core_action(app_handle, WindowsTrayCoreAction::Toggle)
}
WINDOWS_TRAY_RESTART_CORE_MENU_ID => {
run_windows_tray_core_action(app_handle, WindowsTrayCoreAction::Restart)
}
WINDOWS_TRAY_QUIT_MENU_ID => app_handle.exit(0),
_ => {}
})
.on_tray_icon_event(move |tray, event| {
if matches!(
event,
TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
}
) {
show_windows_main_window(tray.app_handle());
}
})
.build(app)?;
let _ = app.manage(WindowsTrayState {
open_main_window,
status_item,
toggle_core_item,
restart_core_item,
quit_item: quit,
locale: Mutex::new(locale),
busy: AtomicBool::new(false),
});
Ok(())
}
fn main() {
let gui_config = match load_or_create_gui_config() {
Ok(config) => config,
Err(error) => {
eprintln!("{error}");
let mut config = GuiConfigFile::default();
if let Err(sanitize_error) = sanitize_gui_config(&mut config) {
eprintln!("初始化固定凭证目录失败: {sanitize_error}");
}
config
}
};
let app = tauri::Builder::default()
.plugin(
tauri_plugin_opener::Builder::new()
.open_js_links_on_click(false)
.build(),
)
.manage(CoreDownloadState::default())
.manage(CoreProcessState::default())
.manage(usage::UsageCollectorState::default())
.manage(GuiConfigState::new(gui_config))
.manage(AgentConfigStatusCache::default());
#[cfg(target_os = "macos")]
let app = app.on_window_event(|window, event| {
if window.label() == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
if !set_macos_dock_visible(false) {
return;
}
if let Err(error) = window.hide() {
eprintln!("隐藏主窗口失败: {error}");
set_macos_dock_visible(true);
}
}
}
});
#[cfg(target_os = "windows")]
let app = app.on_window_event(|window, event| {
if window.label() == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
if let Err(error) = window.emit(WINDOWS_CLOSE_REQUEST_EVENT, ()) {
eprintln!("显示 Windows 关闭确认失败: {error}");
}
}
}
});
let app = app
.setup(|app| {
#[cfg(target_os = "macos")]
setup_macos_tray(app)?;
#[cfg(target_os = "windows")]
setup_windows_tray(app)?;
let usage_app = app.handle().clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(error) = usage::initialize_usage_storage() {
eprintln!("初始化使用记录目录失败: {error}");
}
usage::start_usage_collector(usage_app);
});
let agent_status_app = app.handle().clone();
tauri::async_runtime::spawn_blocking(move || {
let gui_config_state = agent_status_app.state::<GuiConfigState>();
let cache = agent_status_app.state::<AgentConfigStatusCache>();
if let Err(error) = refresh_agent_config_status_cache(
&agent_status_app,
gui_config_state.inner(),
cache.inner(),
) {
eprintln!("后台刷新智能体配置状态失败: {error}");
}
});
let core_app = app.handle().clone();
tauri::async_runtime::spawn_blocking(move || {
let gui_config_state = core_app.state::<GuiConfigState>();
let process_state = core_app.state::<CoreProcessState>();
let Ok(config) = gui_config_state.snapshot() else {
return;
};
if config.run_on_startup {
if let Err(error) = start_core_process_inner(process_state.inner(), &config) {
eprintln!("自动启动 CPA 内核失败: {error}");
}
}
if let Ok(status) =
current_core_status(Some(process_state.inner()), Some(config.port))
{
emit_core_status(&core_app, &status);
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
health_check,
detect_core_platform,
get_core_status,
get_gui_settings,
set_app_locale,
resolve_windows_close_request,
get_agent_config_statuses,
refresh_agent_config_statuses,
get_agent_models,
get_thinking_aliases,
get_thinking_alias_sources,
create_thinking_alias,
delete_thinking_alias,
set_agent_config_enabled,
update_agent_config,
launch_agent,
get_lan_ipv4,
save_gui_settings,
get_core_config_settings,
add_core_api_key,
delete_core_api_key,
set_core_management_secret_key,
clear_core_management_secret_key,
management_request,
upload_auth_file,
download_auth_file,
set_core_plugins_enabled,
set_core_routing_strategy,
start_oauth_login,
get_oauth_status,
submit_oauth_callback,
open_external_url,
check_app_update,
check_latest_core,
detect_bundled_core,
install_core_version,
install_bundled_core,
cancel_core_install,
get_core_install_task,
usage::get_usage_collector_status,
usage::get_usage_overview,
usage::get_usage_analysis,
usage::get_usage_events,
start_core_process,
stop_core_process,
restart_core_process
])
.build(tauri::generate_context!())
.expect("failed to build app");
app.run(|app_handle, event| {
if matches!(event, tauri::RunEvent::Exit) {
usage::stop_usage_collector(app_handle);
let process_state = app_handle.state::<CoreProcessState>();
let gui_config_state = app_handle.state::<GuiConfigState>();
shutdown_managed_core(process_state.inner(), gui_config_state.inner());
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_status_cache_requires_matching_port() {
let cache = AgentConfigStatusCache::default();
cache.replace(8317, Vec::new()).unwrap();
assert!(cache
.get(8317)
.unwrap()
.is_some_and(|statuses| statuses.is_empty()));
assert!(cache.get(8318).unwrap().is_none());
cache.clear().unwrap();
assert!(cache.get(8317).unwrap().is_none());
}
fn agent_test_home(name: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"cpa-gui-agent-{name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&path).unwrap();
path
}
fn test_agent_models(names: &[&str]) -> Vec<AgentModelOption> {
names
.iter()
.map(|name| AgentModelOption {
name: (*name).to_string(),
alias: None,
})
.collect()
}
fn test_codex_models(names: &[&str]) -> Vec<CodexModelCatalogSpec> {
merge_codex_model_catalog_specs(&test_agent_models(names), &[])
}
fn test_codex_oauth_thinking_source(model: &str) -> ResolvedThinkingAliasSource {
ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("codex-oauth:{model}"),
model: model.to_string(),
display_name: None,
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
protocol: "codex".to_string(),
},
location: ThinkingAliasSourceLocation::CodexOauth,
}
}
#[test]
fn gui_config_defaults_are_stable() {
let config = GuiConfigFile::default();
let content = toml::to_string_pretty(&config).unwrap();
assert!(content.contains("port = 8317"));
assert!(content.contains("allow-lan = false"));
assert!(content.contains("run-on-startup = false"));
assert!(content.contains("auth-dir = "));
assert!(content.contains("[[api-keys]]"));
assert!(content.contains("key = \"123456\""));
assert!(content.contains("remark = \"内置密钥\""));
assert!(content.contains("management-secret-key = \"123456\""));
assert!(content.contains("plugins-enabled = false"));
assert!(content.contains("routing-strategy = \"round-robin\""));
}
#[test]
fn claude_agent_config_preserves_existing_fields() {
let rendered = build_claude_agent_config(
Some(r#"{"theme":"dark","env":{"KEEP":"yes"}}"#),
"http://127.0.0.1:8317",
DEFAULT_API_KEY,
"claude-test",
)
.unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["theme"], "dark");
assert_eq!(value["env"]["KEEP"], "yes");
assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "http://127.0.0.1:8317");
assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], DEFAULT_API_KEY);
assert_eq!(value["env"]["ANTHROPIC_MODEL"], "claude-test");
assert_eq!(value["model"], "claude-test");
}
#[test]
fn codex_agent_config_uses_managed_provider_without_losing_comments() {
let rendered = build_codex_agent_config(
Some("# keep this comment\napproval_policy = \"on-request\"\n"),
"http://127.0.0.1:8317/v1",
DEFAULT_API_KEY,
"gpt-test",
)
.unwrap();
let value: toml::Value = toml::from_str(&rendered).unwrap();
assert!(rendered.contains("# keep this comment"));
assert_eq!(value["approval_policy"].as_str(), Some("on-request"));
assert_eq!(
value["model_provider"].as_str(),
Some(MANAGED_AGENT_PROVIDER_ID)
);
assert_eq!(value["model"].as_str(), Some("gpt-test"));
assert_eq!(
value["model_catalog_json"].as_str(),
Some(CODEX_MODEL_CATALOG_FILE)
);
assert_eq!(
value["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(),
Some("http://127.0.0.1:8317/v1")
);
assert_eq!(
value["model_providers"][MANAGED_AGENT_PROVIDER_ID]["experimental_bearer_token"]
.as_str(),
Some(DEFAULT_API_KEY)
);
}
#[test]
fn claude_desktop_config_builds_gateway_profile_and_index() {
let models = test_agent_models(&["claude-sonnet-test", "claude-opus-test"]);
let profile = build_claude_desktop_profile(
Some(r#"{"keep":true}"#),
"http://127.0.0.1:8317",
DEFAULT_API_KEY,
"claude-sonnet-test",
&models,
)
.unwrap();
let meta =
build_claude_desktop_meta(Some(r#"{"entries":[{"id":"other","name":"Other"}]}"#))
.unwrap();
let profile: serde_json::Value = serde_json::from_str(&profile).unwrap();
let meta: serde_json::Value = serde_json::from_str(&meta).unwrap();
assert_eq!(profile["keep"], true);
assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY);
assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317");
assert_eq!(profile["inferenceModels"][0], "claude-sonnet-test");
assert_eq!(profile["inferenceModels"][1], "claude-opus-test");
assert_eq!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID);
assert_eq!(meta["entries"].as_array().unwrap().len(), 2);
}
#[cfg(target_os = "linux")]
#[test]
fn claude_desktop_uses_linux_beta_config_paths() {
let home = agent_test_home("claude-desktop-linux-paths");
let config_home = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| home.join(".config"));
let paths = claude_desktop_config_paths(&home);
assert!(AgentClient::ClaudeDesktop.supported_platform());
assert_eq!(paths.len(), 4);
assert_eq!(
paths[0],
config_home.join("Claude/claude_desktop_config.json")
);
assert_eq!(
paths[1],
config_home.join("Claude-3p/claude_desktop_config.json")
);
assert_eq!(
paths[2],
config_home
.join("Claude-3p/configLibrary")
.join(format!("{CLAUDE_DESKTOP_PROFILE_ID}.json"))
);
assert_eq!(
paths[3],
config_home.join("Claude-3p/configLibrary/_meta.json")
);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn opencode_agent_config_preserves_other_providers() {
let models = test_agent_models(&["gpt-test", "deepseek-test"]);
let rendered = build_opencode_agent_config(
Some(r#"{"theme":"dark","provider":{"other":{"npm":"other"}}}"#),
"http://127.0.0.1:8317/v1",
DEFAULT_API_KEY,
"gpt-test",
&models,
)
.unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["theme"], "dark");
assert_eq!(value["provider"]["other"]["npm"], "other");
assert_eq!(
value["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"],
"http://127.0.0.1:8317/v1"
);
assert_eq!(value["model"], "cpa-gui/gpt-test");
assert!(value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["gpt-test"].is_object());
assert!(
value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["deepseek-test"].is_object()
);
}
#[test]
fn openclaw_agent_config_accepts_json5_and_preserves_unknown_fields() {
let models = test_agent_models(&["gpt-test", "deepseek-test"]);
let rendered = build_openclaw_agent_config(
Some("{ theme: 'dark', models: { mode: 'merge', providers: {} } }"),
"http://127.0.0.1:8317/v1",
DEFAULT_API_KEY,
"gpt-test",
&models,
)
.unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["theme"], "dark");
assert_eq!(
value["models"]["providers"][MANAGED_AGENT_PROVIDER_ID]["api"],
"openai-completions"
);
assert_eq!(
value["agents"]["defaults"]["model"]["primary"],
"cpa-gui/gpt-test"
);
assert_eq!(
value["models"]["providers"][MANAGED_AGENT_PROVIDER_ID]["models"]
.as_array()
.unwrap()
.len(),
2
);
assert!(value["agents"]["defaults"]["models"]["cpa-gui/gpt-test"].is_object());
assert!(value["agents"]["defaults"]["models"]["cpa-gui/deepseek-test"].is_object());
}
#[test]
fn hermes_agent_config_preserves_unknown_fields_and_uses_current_schema() {
let models = test_agent_models(&["gpt-test", "deepseek-test"]);
let rendered = build_hermes_agent_config(
Some("theme: dark\ncustom_providers:\n - name: other\n base_url: https://example.com\n"),
"http://127.0.0.1:8317/v1",
DEFAULT_API_KEY,
"gpt-test",
&models,
)
.unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&rendered).unwrap();
let providers = value["custom_providers"].as_sequence().unwrap();
let managed = providers
.iter()
.find(|provider| provider["name"].as_str() == Some(MANAGED_AGENT_PROVIDER_ID))
.unwrap();
assert_eq!(value["theme"].as_str(), Some("dark"));
assert_eq!(managed["api_mode"].as_str(), Some("chat_completions"));
assert_eq!(managed["model"].as_str(), Some("gpt-test"));
assert!(managed["models"]["gpt-test"].is_mapping());
assert!(managed["models"]["deepseek-test"].is_mapping());
assert_eq!(
value["model"]["provider"].as_str(),
Some(MANAGED_AGENT_PROVIDER_ID)
);
}
#[test]
fn agent_model_list_parser_accepts_core_response_and_deduplicates_ids() {
let models = parse_agent_model_options(&serde_json::json!({
"object": "list",
"data": [
{"id": "gpt-5", "display_name": "GPT 5"},
{"name": "claude-sonnet", "alias": "Sonnet"},
"deepseek-chat",
{"id": "GPT-5", "alias": "duplicate"},
{"id": ""}
]
}))
.unwrap();
assert_eq!(
models,
vec![
AgentModelOption {
name: "gpt-5".to_string(),
alias: Some("GPT 5".to_string()),
},
AgentModelOption {
name: "claude-sonnet".to_string(),
alias: Some("Sonnet".to_string()),
},
AgentModelOption {
name: "deepseek-chat".to_string(),
alias: None,
},
]
);
}
#[test]
fn agent_model_list_parser_rejects_unexpected_response_shape() {
assert!(parse_agent_model_options(&serde_json::json!({"data": null})).is_err());
}
#[test]
fn codex_model_catalog_uses_only_current_cpa_models_and_enriches_matches() {
let models = vec![
AgentModelOption {
name: "deepseek-chat".to_string(),
alias: None,
},
AgentModelOption {
name: "gpt-5.4".to_string(),
alias: None,
},
];
let definitions = parse_codex_model_definitions(&serde_json::json!({
"models": [
{
"id": "gpt-5.4",
"display_name": "GPT 5.4",
"description": "Stable GPT 5.4",
"context_length": 1_050_000,
"supported_parameters": ["tools"],
"thinking": { "levels": ["low", "medium", "high", "xhigh"] }
},
{
"id": "not-open-in-cpa",
"context_length": 999_999
}
]
}))
.unwrap();
let specs = merge_codex_model_catalog_specs(&models, &definitions);
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].id, "deepseek-chat");
assert_eq!(specs[0].context_window, DEFAULT_CODEX_CONTEXT_WINDOW);
assert_eq!(specs[1].id, "gpt-5.4");
assert_eq!(specs[1].display_name, "GPT 5.4");
assert_eq!(specs[1].context_window, 1_050_000);
assert!(!specs.iter().any(|model| model.id == "not-open-in-cpa"));
}
#[test]
fn codex_model_catalog_renders_codex_supported_fields() {
let rendered = build_codex_model_catalog(&test_codex_models(&["gpt-test"])).unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
let model = &value["models"][0];
assert_eq!(model["slug"], "gpt-test");
assert_eq!(model["context_window"], DEFAULT_CODEX_CONTEXT_WINDOW);
assert_eq!(model["default_reasoning_level"], "medium");
assert_eq!(model["shell_type"], "shell_command");
assert_eq!(model["include_skills_usage_instructions"], true);
assert_eq!(
model["supported_reasoning_levels"]
.as_array()
.unwrap()
.len(),
4
);
}
#[test]
fn agent_model_validation_only_accepts_models_in_current_list() {
let models = vec![AgentModelOption {
name: "gpt-5.4".to_string(),
alias: Some("GPT 5.4".to_string()),
}];
assert_eq!(
resolve_available_agent_model(&models, "GPT-5.4").unwrap(),
"gpt-5.4"
);
assert!(resolve_available_agent_model(&models, "removed-model").is_err());
assert!(resolve_available_agent_model(&[], "gpt-5.4").is_err());
}
#[test]
fn thinking_alias_sources_only_include_current_core_models() {
let input = "codex-api-key:\n - name: Codex API\n api-key: test\n models:\n - name: config-only\nopenai-compatibility:\n - name: DeepSeek\n base-url: https://api.deepseek.com\n api-key-entries:\n - api-key: test\n models:\n - name: DeepSeek-Chat\n";
let definitions = parse_codex_model_definitions(&serde_json::json!({
"models": [
{
"id": "gpt-runtime",
"thinking": { "levels": ["low", "high"] }
},
{
"id": "gpt-built-in-only",
"thinking": { "levels": ["low", "high"] }
}
]
}))
.unwrap();
let available_models = test_agent_models(&["GPT-RUNTIME", "deepseek-chat"]);
let sources =
resolved_thinking_alias_sources(input, &definitions, &available_models).unwrap();
let source_models = sources
.iter()
.map(|source| source.source.model.as_str())
.collect::<Vec<_>>();
assert_eq!(source_models, vec!["gpt-runtime", "DeepSeek-Chat"]);
assert!(!source_models.contains(&"gpt-built-in-only"));
assert!(!source_models.contains(&"config-only"));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_batch_agent_commands_use_call_without_embedded_quotes() {
let executable = Path::new(r"C:\工具 目录\opencode.cmd");
let command = windows_command_for_executable(executable, true);
let args = command
.get_args()
.take(3)
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
Path::new(command.get_program())
.file_name()
.and_then(|name| name.to_str()),
Some("cmd.exe")
);
assert_eq!(
args,
vec!["/D".to_string(), "/K".to_string(), "call".to_string(),]
);
assert_eq!(
windows_batch_executable_argument(executable),
r#""C:\工具 目录\opencode.cmd""#
);
let batch = windows_command_for_executable(Path::new(r"C:\tools\agent.bat"), false);
let batch_args = batch
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(batch_args[1], "/C");
assert_eq!(batch_args[2], "call");
let native = windows_command_for_executable(Path::new(r"C:\tools\agent.exe"), true);
assert_eq!(
native.get_program().to_string_lossy(),
r"C:\tools\agent.exe"
);
assert_eq!(native.get_args().count(), 0);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_tray_presentation_tracks_core_state_and_busy_actions() {
let mut status = CoreStatus {
installed: false,
running: false,
managed: false,
process_id: None,
current_version: None,
install_dir: String::new(),
binary_path: None,
message: String::new(),
};
let missing = windows_tray_presentation(&status, false, "zh-CN");
assert_eq!(missing.status_text, "内核状态:未安装");
assert!(!missing.toggle_enabled);
assert!(!missing.restart_enabled);
status.installed = true;
let stopped = windows_tray_presentation(&status, false, "zh-CN");
assert_eq!(stopped.toggle_text, "启动内核");
assert!(stopped.toggle_enabled);
assert!(!stopped.restart_enabled);
status.running = true;
let running = windows_tray_presentation(&status, false, "zh-CN");
assert_eq!(running.status_text, "内核状态:运行中");
assert_eq!(running.toggle_text, "停止内核");
assert!(running.toggle_enabled);
assert!(running.restart_enabled);
let busy = windows_tray_presentation(&status, true, "zh-CN");
assert_eq!(busy.status_text, "内核状态:处理中");
assert!(!busy.toggle_enabled);
assert!(!busy.restart_enabled);
let english = windows_tray_presentation(&status, false, "en-US");
assert_eq!(english.status_text, "Core status: Running");
assert_eq!(english.toggle_text, "Stop Core");
let japanese = windows_tray_presentation(&status, false, "ja-JP");
assert_eq!(japanese.status_text, "コア状態:実行中");
assert_eq!(japanese.toggle_text, "コアを停止");
}
#[test]
fn app_locale_normalization_has_a_stable_chinese_fallback() {
assert_eq!(normalize_app_locale("en"), "en");
assert_eq!(normalize_app_locale("en-US"), "en");
assert_eq!(normalize_app_locale("ja-JP"), "ja");
assert_eq!(normalize_app_locale("zh-TW"), "zh-TW");
assert_eq!(normalize_app_locale("zh-Hant-HK"), "zh-TW");
assert_eq!(normalize_app_locale("unsupported"), "zh-CN");
assert_eq!(GuiConfigFile::default().locale, "zh-CN");
}
#[cfg(target_os = "windows")]
#[test]
fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() {
let app =
parse_windows_codex_app_discovery_output("APPID:OpenAI.Codex_2p2nqsd0c76g0!App\r\n")
.unwrap();
match app {
CodexAppTarget::WindowsAppId(app_id) => {
assert_eq!(app_id, "OpenAI.Codex_2p2nqsd0c76g0!App");
}
CodexAppTarget::Application(_) => panic!("expected Store application ID"),
}
let executable = parse_windows_codex_app_discovery_output(
"warning\r\nEXE:C:\\Program Files\\OpenAI\\ChatGPT\\ChatGPT.exe\r\n",
)
.unwrap();
match executable {
CodexAppTarget::Application(path) => {
assert_eq!(
path,
PathBuf::from(r"C:\Program Files\OpenAI\ChatGPT\ChatGPT.exe")
);
}
CodexAppTarget::WindowsAppId(_) => panic!("expected desktop executable"),
}
assert!(parse_windows_codex_app_discovery_output("MSEdgePWA:ChatGPT\r\n").is_none());
let registry_output = r"HKEY_CURRENT_USER\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages\OpenAI.Codex_26.715.4045.0_x64__2p2nqsd0c76g0";
assert_eq!(
parse_windows_codex_app_id_from_registry(registry_output).as_deref(),
Some("OpenAI.Codex_2p2nqsd0c76g0!App")
);
assert_eq!(
windows_codex_app_id_from_package_full_name(
"OpenAI.ChatGPT_1.2.3.4_arm64__2p2nqsd0c76g0"
)
.as_deref(),
Some("OpenAI.ChatGPT_2p2nqsd0c76g0!App")
);
assert!(windows_codex_app_id_from_package_full_name(
"Microsoft.MicrosoftEdge_1.0.0.0_x64__8wekyb3d8bbwe"
)
.is_none());
}
#[test]
fn thinking_alias_adds_fork_and_matching_payload_rule() {
let input = "# Keep this comment\ndebug: true\npayload:\n override:\n - models:\n - name: existing-fast\n protocol: codex\n params:\n service_tier: priority\n";
let source = test_codex_oauth_thinking_source("gpt-5.5");
let rendered =
add_thinking_alias_to_yaml(input, &source, "gpt-5.5-xhigh", "xhigh").unwrap();
let aliases = thinking_aliases_from_yaml(&rendered).unwrap();
assert!(rendered.contains("# Keep this comment"), "{rendered}");
assert!(rendered.contains("service_tier: priority"), "{rendered}");
assert_eq!(
aliases,
vec![ThinkingAliasEntry {
source_model: "gpt-5.5".to_string(),
alias: "gpt-5.5-xhigh".to_string(),
effort: Some("xhigh".to_string()),
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
}]
);
}
#[test]
fn thinking_alias_effort_accepts_provider_defined_levels() {
assert_eq!(validate_thinking_alias_effort(" AUTO ").unwrap(), "auto");
assert_eq!(validate_thinking_alias_effort("ultra").unwrap(), "ultra");
assert_eq!(
validate_thinking_alias_effort("vendor_level-2.1").unwrap(),
"vendor_level-2.1"
);
assert!(validate_thinking_alias_effort("").is_err());
assert!(validate_thinking_alias_effort("high value").is_err());
assert!(validate_thinking_alias_effort("32768").is_err());
}
#[test]
fn thinking_alias_removal_keeps_other_models_in_grouped_rule() {
let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-xhigh\n fork: true\n - name: gpt-5.4\n alias: gpt-5.4-xhigh\n fork: true\npayload:\n override:\n - models:\n - name: gpt-5.5-xhigh\n protocol: codex\n - name: gpt-5.4-xhigh\n protocol: codex\n params:\n reasoning.effort: xhigh\n";
let rendered = remove_thinking_alias_from_yaml(input, "gpt-5.5-xhigh").unwrap();
let aliases = thinking_aliases_from_yaml(&rendered).unwrap();
assert_eq!(aliases.len(), 1);
assert_eq!(aliases[0].alias, "gpt-5.4-xhigh");
assert!(!rendered.contains("gpt-5.5-xhigh"), "{rendered}");
assert!(rendered.contains("gpt-5.4-xhigh"), "{rendered}");
assert!(rendered.contains("reasoning.effort: xhigh"), "{rendered}");
}
#[test]
fn thinking_alias_rejects_duplicate_client_visible_name() {
let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-high\n fork: true\n";
let source = test_codex_oauth_thinking_source("gpt-5.4");
assert!(
add_thinking_alias_to_yaml(input, &source, "GPT-5.5-HIGH", "high")
.unwrap_err()
.contains("已存在")
);
}
#[test]
fn thinking_alias_supports_openai_compatible_model_entries() {
let input = "openai-compatibility:\n - name: DeepSeek\n base-url: https://api.deepseek.com\n api-key-entries:\n - api-key: test\n models:\n - name: deepseek-chat\n display-name: DeepSeek Chat\n thinking:\n levels: [low, medium, high]\n";
let available_models = test_agent_models(&["deepseek-chat"]);
let sources = resolved_thinking_alias_sources(input, &[], &available_models).unwrap();
let source = sources
.iter()
.find(|source| source.source.model == "deepseek-chat")
.unwrap();
let rendered =
add_thinking_alias_to_yaml(input, source, "deepseek-chat-high", "high").unwrap();
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
let root = value.as_mapping().unwrap();
let providers = yaml_mapping_value(root, "openai-compatibility")
.and_then(serde_norway::Value::as_sequence)
.unwrap();
let models = yaml_mapping_value(providers[0].as_mapping().unwrap(), "models")
.and_then(serde_norway::Value::as_sequence)
.unwrap();
let alias_model = models[1].as_mapping().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(
yaml_mapping_value(alias_model, "name").and_then(serde_norway::Value::as_str),
Some("deepseek-chat")
);
assert_eq!(
yaml_mapping_value(alias_model, "alias").and_then(serde_norway::Value::as_str),
Some("deepseek-chat-high")
);
assert!(rendered.contains("protocol: openai"), "{rendered}");
assert!(rendered.contains("reasoning_effort: high"), "{rendered}");
assert!(rendered.contains("thinking.type: enabled"), "{rendered}");
assert!(!rendered.contains("oauth-model-alias"), "{rendered}");
let entries = thinking_aliases_from_yaml(&rendered).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].provider, "DeepSeek");
assert_eq!(entries[0].kind, "openai-compatible");
let restored = remove_thinking_alias_from_yaml(&rendered, "deepseek-chat-high").unwrap();
assert!(!restored.contains("deepseek-chat-high"), "{restored}");
assert!(!restored.contains("reasoning_effort"), "{restored}");
}
#[test]
fn thinking_alias_supports_codex_api_model_entries() {
let input = "codex-api-key:\n - api-key: test\n base-url: https://example.com/v1\n models:\n - name: gpt-custom\n";
let available_models = test_agent_models(&["gpt-custom"]);
let sources = resolved_thinking_alias_sources(input, &[], &available_models).unwrap();
let source = sources
.iter()
.find(|source| source.source.kind == "codex-api")
.unwrap();
let rendered =
add_thinking_alias_to_yaml(input, source, "gpt-custom-xhigh", "xhigh").unwrap();
assert!(rendered.contains("alias: gpt-custom-xhigh"), "{rendered}");
assert!(rendered.contains("protocol: codex"), "{rendered}");
assert!(rendered.contains("reasoning.effort: xhigh"), "{rendered}");
assert!(!rendered.contains("oauth-model-alias"), "{rendered}");
let entries = thinking_aliases_from_yaml(&rendered).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind, "codex-api");
}
#[test]
fn agent_modification_backs_up_updates_conflicts_and_restores_exact_bytes() {
let home = agent_test_home("codex-roundtrip");
let path = home.join(".codex/config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let original = b"# original comment\napproval_policy = \"on-request\"\n";
fs::write(&path, original).unwrap();
let available_models = test_agent_models(&["gpt-one", "gpt-two", "gpt-three"]);
let codex_models = test_codex_models(&["gpt-one", "gpt-two", "gpt-three"]);
let enabled = enable_agent_modification(
AgentClient::Codex,
&home,
8317,
"gpt-one",
&available_models,
Some(&codex_models),
)
.unwrap();
assert_eq!(enabled.outcome, "enabled");
let backup = agent_backup_path(&path).unwrap();
let state = agent_state_path(std::slice::from_ref(&path)).unwrap();
let catalog_path = codex_model_catalog_path(&home);
assert_eq!(fs::read(&backup).unwrap(), original);
assert!(state.is_file());
assert!(catalog_path.is_file());
let updated = update_agent_modification(
AgentClient::Codex,
&home,
8317,
"gpt-two",
&available_models,
Some(&codex_models),
)
.unwrap();
assert_eq!(updated.outcome, "updated");
assert_eq!(fs::read(&backup).unwrap(), original);
assert!(fs::read_to_string(&path).unwrap().contains("gpt-two"));
fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap()
.write_all(b"# external change\n")
.unwrap();
assert!(update_agent_modification(
AgentClient::Codex,
&home,
8317,
"gpt-three",
&available_models,
Some(&codex_models),
)
.unwrap_err()
.contains("其他程序修改"));
let conflict = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
assert_eq!(conflict.outcome, "restore-conflict");
assert_eq!(conflict.conflict_files, vec![path_to_string(&path)]);
assert!(state.is_file());
let restored = disable_agent_modification(AgentClient::Codex, &home, 8317, true).unwrap();
assert_eq!(restored.outcome, "disabled");
assert_eq!(fs::read(&path).unwrap(), original);
assert!(!catalog_path.exists());
assert!(!backup.exists());
assert!(!state.exists());
fs::remove_dir_all(home).unwrap();
}
#[test]
fn agent_modification_removes_files_that_did_not_exist_before_enable() {
let home = agent_test_home("opencode-absent");
let path = home.join(".config/opencode/opencode.json");
let models = test_agent_models(&["gpt-test"]);
enable_agent_modification(
AgentClient::OpenCode,
&home,
8317,
"gpt-test",
&models,
None,
)
.unwrap();
assert!(path.is_file());
assert!(!agent_backup_path(&path).unwrap().exists());
disable_agent_modification(AgentClient::OpenCode, &home, 8317, false).unwrap();
assert!(!path.exists());
assert!(!agent_state_path(&[path]).unwrap().exists());
fs::remove_dir_all(home).unwrap();
}
#[test]
fn agent_enable_discards_backup_when_state_cannot_be_written() {
let home = agent_test_home("state-write-failure");
let path = home.join(".codex/config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"approval_policy = \"never\"\n").unwrap();
let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap();
fs::create_dir(&state_path).unwrap();
let available_models = test_agent_models(&["gpt-test"]);
let codex_models = test_codex_models(&["gpt-test"]);
assert!(enable_agent_modification(
AgentClient::Codex,
&home,
8317,
"gpt-test",
&available_models,
Some(&codex_models),
)
.is_err());
assert_eq!(fs::read(&path).unwrap(), b"approval_policy = \"never\"\n");
assert!(!agent_backup_path(&path).unwrap().exists());
fs::remove_dir_all(home).unwrap();
}
#[test]
fn claude_code_keeps_using_the_path_that_owns_active_state() {
let home = agent_test_home("claude-path-state");
let directory = home.join(".claude");
let settings = directory.join("settings.json");
let legacy = directory.join("claude.json");
fs::create_dir_all(&directory).unwrap();
fs::write(&settings, b"{}\n").unwrap();
fs::write(&legacy, b"{}\n").unwrap();
fs::write(
agent_state_path(std::slice::from_ref(&legacy)).unwrap(),
b"{}\n",
)
.unwrap();
assert_eq!(
agent_config_paths(AgentClient::ClaudeCode, &home),
vec![legacy]
);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn legacy_agent_backup_restores_even_when_gui_port_changed() {
let home = agent_test_home("legacy-port");
let path = home.join(".codex/config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let original = b"approval_policy = \"never\"\n";
fs::write(agent_backup_path(&path).unwrap(), original).unwrap();
fs::write(
&path,
build_codex_agent_config(
None,
"http://127.0.0.1:9999/v1",
DEFAULT_API_KEY,
"gpt-legacy",
)
.unwrap(),
)
.unwrap();
assert!(agent_has_managed_marker(AgentClient::Codex, std::slice::from_ref(&path)).unwrap());
let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
assert_eq!(result.outcome, "disabled");
assert_eq!(fs::read(&path).unwrap(), original);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn legacy_generated_only_agent_config_is_removed_without_backup() {
let home = agent_test_home("legacy-generated");
let path = home.join(".codex/config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
build_codex_agent_config(
None,
"http://127.0.0.1:8317/v1",
DEFAULT_API_KEY,
"gpt-generated",
)
.unwrap(),
)
.unwrap();
let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
assert_eq!(result.outcome, "disabled");
assert!(!path.exists());
fs::remove_dir_all(home).unwrap();
}
#[test]
fn updating_legacy_codex_state_adds_catalog_without_replacing_original_backup() {
let home = agent_test_home("legacy-catalog-upgrade");
let path = home.join(".codex/config.toml");
let catalog_path = codex_model_catalog_path(&home);
fs::create_dir_all(path.parent().unwrap()).unwrap();
let original_config = b"approval_policy = \"never\"\n";
let original_catalog = b"{\"models\":[{\"slug\":\"user-model\"}]}\n";
fs::write(agent_backup_path(&path).unwrap(), original_config).unwrap();
fs::write(
&path,
build_codex_agent_config(None, "http://127.0.0.1:8317/v1", DEFAULT_API_KEY, "gpt-old")
.unwrap(),
)
.unwrap();
fs::write(&catalog_path, original_catalog).unwrap();
let record = build_legacy_agent_record(AgentClient::Codex, &home, 8317, "gpt-old")
.unwrap()
.unwrap();
assert_eq!(record.files.len(), 1);
write_agent_state(
&agent_state_path(std::slice::from_ref(&path)).unwrap(),
&record,
)
.unwrap();
let available_models = test_agent_models(&["gpt-new"]);
let codex_models = test_codex_models(&["gpt-new"]);
update_agent_modification(
AgentClient::Codex,
&home,
8317,
"gpt-new",
&available_models,
Some(&codex_models),
)
.unwrap();
let upgraded = load_agent_record(AgentClient::Codex, std::slice::from_ref(&path))
.unwrap()
.unwrap();
assert_eq!(upgraded.files.len(), 2);
assert_eq!(
fs::read(agent_backup_path(&path).unwrap()).unwrap(),
original_config
);
assert_eq!(
fs::read(agent_backup_path(&catalog_path).unwrap()).unwrap(),
original_catalog
);
disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
assert_eq!(fs::read(&path).unwrap(), original_config);
assert_eq!(fs::read(&catalog_path).unwrap(), original_catalog);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn interrupted_agent_state_is_reported_as_recovery() {
let home = agent_test_home("recovery-state");
let path = home.join(".codex/config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "approval_policy = \"never\"\n").unwrap();
let available_models = test_agent_models(&["gpt-test"]);
let codex_models = test_codex_models(&["gpt-test"]);
enable_agent_modification(
AgentClient::Codex,
&home,
8317,
"gpt-test",
&available_models,
Some(&codex_models),
)
.unwrap();
let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap();
let mut record = load_agent_record(AgentClient::Codex, &[path])
.unwrap()
.unwrap();
record.phase = AGENT_PHASE_APPLYING.to_string();
write_agent_state(&state_path, &record).unwrap();
let inspection =
inspect_agent_modification(AgentClient::Codex, &home, 8317, true, Some("gpt-test"));
assert!(inspection.enabled);
assert_eq!(inspection.state, "recovery");
disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
fs::remove_dir_all(home).unwrap();
}
#[test]
fn agent_backup_protocol_restores_multiple_files_and_removes_created_files() {
let home = agent_test_home("multi-file");
let first = home.join("first.json");
let second = home.join("second.json");
fs::write(&first, b"{\"original\":true}\n").unwrap();
let updates = vec![
AgentFileUpdate {
path: first.clone(),
after: "{\"managed\":1}\n".to_string(),
},
AgentFileUpdate {
path: second.clone(),
after: "{\"managed\":2}\n".to_string(),
},
];
let record = prepare_agent_record(
AgentClient::ClaudeDesktop,
&[first.clone(), second.clone()],
"claude-test",
&updates,
)
.unwrap();
apply_agent_updates(&updates).unwrap();
assert!(first.is_file());
assert!(second.is_file());
restore_agent_record_files(&record).unwrap();
assert_eq!(fs::read_to_string(&first).unwrap(), "{\"original\":true}\n");
assert!(!second.exists());
fs::remove_dir_all(home).unwrap();
}
#[test]
fn interrupted_multi_file_apply_can_restore_without_false_conflict() {
let home = agent_test_home("partial-recovery");
let first = home.join("first.json");
let second = home.join("second.json");
fs::write(&first, b"{\"original\":1}\n").unwrap();
fs::write(&second, b"{\"original\":2}\n").unwrap();
let updates = vec![
AgentFileUpdate {
path: first.clone(),
after: "{\"managed\":1}\n".to_string(),
},
AgentFileUpdate {
path: second.clone(),
after: "{\"managed\":2}\n".to_string(),
},
];
let record = prepare_agent_record(
AgentClient::ClaudeDesktop,
&[first.clone(), second.clone()],
"claude-test",
&updates,
)
.unwrap();
write_bytes_atomically(&first, updates[0].after.as_bytes()).unwrap();
assert_eq!(
record_conflict_files(&record).unwrap(),
vec![path_to_string(&second)]
);
assert!(record_restore_conflict_files(&record).unwrap().is_empty());
fs::write(&second, b"{\"external\":true}\n").unwrap();
assert_eq!(
record_restore_conflict_files(&record).unwrap(),
vec![path_to_string(&second)]
);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn partial_agent_write_failure_rolls_back_previous_files() {
let home = agent_test_home("partial-write");
let first = home.join("first.txt");
let blocked_parent = home.join("blocked");
fs::write(&first, b"original\n").unwrap();
fs::write(&blocked_parent, b"not a directory").unwrap();
let updates = vec![
AgentFileUpdate {
path: first.clone(),
after: "changed\n".to_string(),
},
AgentFileUpdate {
path: blocked_parent.join("second.txt"),
after: "never written\n".to_string(),
},
];
assert!(apply_agent_updates(&updates).is_err());
assert_eq!(fs::read_to_string(&first).unwrap(), "original\n");
fs::remove_dir_all(home).unwrap();
}
#[test]
fn legacy_string_api_keys_gain_remarks_and_keep_custom_keys() {
let legacy = "port = 8317\nallow-lan = false\nrun-on-startup = false\nauth-dir = \"/tmp/oauth\"\napi-keys = [\"123456\", \"custom-key\"]\nmanagement-secret-key = \"123456\"\nplugins-enabled = false\nrouting-strategy = \"round-robin\"\n";
let mut config = toml::from_str::<GuiConfigFile>(legacy).unwrap();
assert!(sanitize_gui_config(&mut config).unwrap());
assert_eq!(
gui_api_key_values(&config.api_keys),
vec!["123456", "custom-key"]
);
assert_eq!(config.api_keys[0].remark, DEFAULT_API_KEY_REMARK);
assert!(config.api_keys[1].remark.is_empty());
let serialized = toml::to_string_pretty(&config).unwrap();
assert!(serialized.contains("[[api-keys]]"));
assert!(serialized.contains("remark = \"内置密钥\""));
let reparsed = toml::from_str::<GuiConfigFile>(&serialized).unwrap();
assert_eq!(reparsed.api_keys, config.api_keys);
}
#[test]
fn api_key_remarks_follow_matching_core_keys() {
let existing = vec![
built_in_api_key_entry(),
GuiApiKeyEntry {
key: "custom-key".to_string(),
remark: "开发环境".to_string(),
},
];
let core_keys = vec!["custom-key".to_string(), "new-key".to_string()];
let merged = merge_core_api_keys_with_gui_metadata(&existing, &core_keys, None);
assert_eq!(
gui_api_key_values(&merged),
vec!["123456", "custom-key", "new-key"]
);
assert_eq!(merged[1].remark, "开发环境");
assert!(merged[2].remark.is_empty());
}
#[test]
fn core_config_view_exposes_api_key_metadata_for_the_webview() {
let view = serde_json::to_value(CoreConfigView::from(&GuiConfigFile::default())).unwrap();
assert_eq!(view["apiKeys"][0]["apiKey"], DEFAULT_API_KEY);
assert_eq!(view["apiKeys"][0]["remark"], DEFAULT_API_KEY_REMARK);
assert_eq!(view["apiKeys"][0]["builtIn"], true);
}
#[test]
fn management_secret_key_is_normalized_to_fixed_value() {
let mut config = GuiConfigFile {
management_secret_key: "old-management-secret".to_string(),
..GuiConfigFile::default()
};
assert!(sanitize_gui_config(&mut config).unwrap());
assert_eq!(config.management_secret_key, DEFAULT_MANAGEMENT_SECRET_KEY);
let template = "remote-management:\n secret-key: stale-secret\n";
let merged = merge_core_config_yaml(template, None, &config).unwrap();
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
assert_eq!(
document["remote-management"]["secret-key"],
DEFAULT_MANAGEMENT_SECRET_KEY
);
}
#[test]
fn auth_directory_is_fixed_and_written_to_core_config() {
let mut config = GuiConfigFile {
auth_dir: "/tmp/user-selected-auth".to_string(),
..GuiConfigFile::default()
};
assert!(validate_gui_config(&config).is_err());
assert!(sanitize_gui_config(&mut config).unwrap());
assert_eq!(config.auth_dir, path_to_string(&fixed_oauth_dir().unwrap()));
let merged = merge_core_config_yaml("auth-dir: ~/.cli-proxy-api\n", None, &config).unwrap();
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
assert_eq!(document["auth-dir"], config.auth_dir);
}
#[test]
fn legacy_gui_config_can_seed_managed_core_settings() {
let legacy = "port: 8317\nallow-lan: false\nrun-on-startup: true\n";
let mut config = serde_yaml::from_str::<GuiConfigFile>(legacy).unwrap();
let presence = serde_yaml::from_str::<GuiConfigPresence>(legacy).unwrap();
let core_settings = CoreConfigSettings {
api_keys: vec!["existing-key".to_string()],
management_secret_configured: true,
plugins_enabled: true,
routing_strategy: "fill-first".to_string(),
management_secret_key: Some("management-secret".to_string()),
};
assert!(presence.api_keys.is_none());
assert!(presence.management_secret_key.is_none());
assert!(presence.plugins_enabled.is_none());
assert!(presence.routing_strategy.is_none());
apply_core_settings_to_gui_config(&mut config, &core_settings);
assert_eq!(
gui_api_key_values(&config.api_keys),
vec!["123456", "existing-key"]
);
assert_eq!(config.management_secret_key, DEFAULT_MANAGEMENT_SECRET_KEY);
assert!(config.plugins_enabled);
assert_eq!(config.routing_strategy, "fill-first");
assert!(config.run_on_startup);
}
#[test]
fn example_api_keys_are_not_persisted_as_gui_settings() {
let input = "api-keys:\n - your-api-key-1\n - real-key\nremote-management:\n secret-key: plain-management-secret\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\n";
let document = serde_norway::from_str::<serde_norway::Value>(input).unwrap();
let core_settings = core_config_settings_from_value(&document).unwrap();
let mut config = GuiConfigFile::default();
apply_core_settings_to_gui_config(&mut config, &core_settings);
assert_eq!(core_settings.api_keys, vec!["real-key"]);
assert_eq!(
gui_api_key_values(&config.api_keys),
vec!["123456", "real-key"]
);
assert_eq!(
core_settings.management_secret_key.as_deref(),
Some("plain-management-secret")
);
assert_eq!(config.management_secret_key, DEFAULT_MANAGEMENT_SECRET_KEY);
assert!(validate_core_api_key("your-api-key-3").is_err());
}
#[test]
fn hashed_management_secret_is_not_imported_as_gui_source() {
let input = "remote-management:\n secret-key: $2a$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuu\n";
let document = serde_norway::from_str::<serde_norway::Value>(input).unwrap();
let core_settings = core_config_settings_from_value(&document).unwrap();
assert!(core_settings.management_secret_key.is_none());
assert!(!core_settings.management_secret_configured);
}
#[test]
fn runtime_network_patch_preserves_comments_and_other_settings() {
let config = GuiConfigFile {
locale: "zh-CN".to_string(),
port: 9527,
allow_lan: true,
run_on_startup: false,
..GuiConfigFile::default()
};
let input = "# Bind address\nhost: 127.0.0.1 # local only\n\n# Service port\nport: 8317 # default\ndebug: true\n";
let updated = patch_core_network_yaml(input, &config)
.unwrap()
.expect("network settings should change");
assert_eq!(
updated,
"# Bind address\nhost: 0.0.0.0 # local only\n\n# Service port\nport: 9527 # default\ndebug: true\n"
);
assert!(updated.contains("# Bind address"));
assert!(updated.contains("# local only"));
assert!(updated.contains("# Service port"));
assert!(updated.contains("# default"));
assert!(updated.contains("debug: true"));
let document = serde_norway::from_str::<serde_norway::Value>(&updated).unwrap();
assert_eq!(
document["host"],
serde_norway::Value::String("0.0.0.0".to_string())
);
assert_eq!(document["port"], serde_norway::to_value(9527_u16).unwrap());
}
#[test]
fn runtime_network_patch_skips_unchanged_yaml() {
let config = GuiConfigFile::default();
let input = "host: 127.0.0.1\nport: 8317\n";
assert!(patch_core_network_yaml(input, &config).unwrap().is_none());
}
#[test]
fn core_config_controls_preserve_comments_and_unrelated_values() {
let input = "# Client authentication\napi-keys:\n - old-key\n\n# Plugin runtime\nplugins:\n enabled: false # global switch\n dir: plugins\n\n# Credential routing\nrouting:\n strategy: round-robin # current strategy\n session-affinity: true\n\ndebug: true # untouched\n";
let mut document = yaml_serde_edit::YamlValue::parse(input).unwrap();
let mut updated = document.get().clone();
set_core_api_keys(
&mut updated,
vec!["old-key".to_string(), "new-key".to_string()],
)
.unwrap();
set_nested_yaml_value(&mut updated, &["plugins", "enabled"], true).unwrap();
set_nested_yaml_value(
&mut updated,
&["routing", "strategy"],
"fill-first".to_string(),
)
.unwrap();
document.set(updated);
let rendered = document.get_string();
assert!(rendered.contains("# Client authentication"));
assert!(rendered.contains("# Plugin runtime"));
assert!(rendered.contains("# global switch"));
assert!(rendered.contains("# Credential routing"));
assert!(rendered.contains("# current strategy"));
assert!(rendered.contains("debug: true # untouched"));
let settings = core_config_settings_from_value(document.get()).unwrap();
assert_eq!(settings.api_keys, vec!["old-key", "new-key"]);
assert!(settings.plugins_enabled);
assert_eq!(settings.routing_strategy, "fill-first");
assert_eq!(document.get()["plugins"]["dir"], "plugins");
assert_eq!(document.get()["routing"]["session-affinity"], true);
}
#[test]
fn yaml_edit_runtime_patches_supported_fields_without_reflowing_yaml() {
let input = "# Client authentication\napi-keys:\n - old-key\n\n# Plugin runtime\nplugins:\n enabled: false # global switch\n dir: plugins\n\n# Credential routing\nrouting:\n strategy: round-robin # current strategy\n session-affinity: true\n\ndebug: true # untouched\n";
let file = input.parse::<yaml_edit::YamlFile>().unwrap();
let document = file.document().unwrap();
assert!(set_yaml_edit_nested_value(
&document, "plugins", "enabled", true
));
assert!(set_yaml_edit_nested_value(
&document,
"routing",
"strategy",
"fill-first".to_string()
));
let rendered = patch_core_api_keys_yaml(
&file.to_string(),
&["new-key".to_string(), "backup-key".to_string()],
)
.unwrap();
assert!(rendered.contains("# Client authentication"));
assert!(rendered.contains("# Plugin runtime"));
assert!(rendered.contains("# global switch"));
assert!(rendered.contains("# Credential routing"));
assert!(rendered.contains("# current strategy"));
assert!(rendered.contains("debug: true # untouched"));
assert!(rendered.contains("dir: plugins"));
assert!(rendered.contains("session-affinity: true"));
let settings =
core_config_settings_from_value(&serde_norway::from_str(&rendered).unwrap()).unwrap();
assert_eq!(settings.api_keys, vec!["new-key", "backup-key"]);
assert!(settings.plugins_enabled);
assert_eq!(settings.routing_strategy, "fill-first");
}
#[test]
fn yaml_edit_sequence_shrink_keeps_following_top_level_key_valid() {
let input = "# API keys for authentication\napi-keys:\n - first-key\n - second-key\n - third-key\n\n# Enable debug logging\ndebug: false\n";
let original = serde_norway::from_str::<serde_norway::Value>(input).unwrap();
let mut updated = original.clone();
set_core_api_keys(&mut updated, vec!["first-key".to_string()]).unwrap();
let rendered = render_yaml_value_changes(input, &original, &updated)
.unwrap_or_else(|error| panic!("sequence shrink failed: {error}"));
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
assert_eq!(parsed["api-keys"][0], "first-key");
assert_eq!(parsed["api-keys"].as_sequence().unwrap().len(), 1);
assert_eq!(parsed["debug"], false);
assert!(rendered.find("api-keys:").unwrap() < rendered.find("debug:").unwrap());
}
#[test]
fn runtime_yaml_ast_patch_handles_core_comments_around_nested_mapping() {
let input = "host: 127.0.0.1\nremote-management:\n# Whether to allow remote access.\n allow-remote: false\n# Management key.\n# All requests require this key.\n secret-key: old\n# Disable panel.\n disable-control-panel: false\nauth-dir: /tmp/old\napi-keys:\n - old-key\n";
let rendered = patch_core_yaml_document(input, |document| {
let auth_changed = set_core_yaml_top_level_value(
document,
"auth-dir",
serde_norway::Value::String("/tmp/new".to_string()),
)?;
let secret_changed = set_core_yaml_nested_value(
document,
"remote-management",
"secret-key",
serde_norway::Value::String("123456".to_string()),
)?;
Ok(auth_changed || secret_changed)
})
.unwrap()
.unwrap();
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
assert_eq!(parsed["auth-dir"], "/tmp/new");
assert_eq!(parsed["remote-management"]["secret-key"], "123456");
assert!(rendered.contains("# All requests require this key."));
assert!(rendered.contains("disable-control-panel: false"));
}
#[test]
fn yaml_edit_runtime_patch_removes_empty_keys_and_skips_unsupported_sections() {
let input = "# Client authentication\napi-keys:\n - old-key\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\n";
let rendered = patch_core_api_keys_yaml(input, &[]).unwrap();
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered).unwrap();
let root = parsed.as_mapping().unwrap();
assert!(yaml_mapping_value(root, "api-keys").is_none(), "{rendered}");
assert_eq!(
core_config_settings_from_value(&parsed).unwrap().api_keys,
Vec::<String>::new()
);
let missing_api_keys = "host: 127.0.0.1\nport: 8317\n";
let rendered = patch_core_api_keys_yaml(
missing_api_keys,
&["new-key".to_string(), "backup-key".to_string()],
)
.unwrap();
let settings =
core_config_settings_from_value(&serde_norway::from_str(&rendered).unwrap()).unwrap();
assert_eq!(settings.api_keys, vec!["new-key", "backup-key"]);
assert!(rendered.contains("host: 127.0.0.1"));
assert!(rendered.contains("port: 8317"));
// Nested plugin/routing sections are still optional for comment-preserving
// runtime patches; missing maps remain unsupported and stay untouched.
let unsupported = "host: 127.0.0.1\nport: 8317\n";
let file = unsupported.parse::<yaml_edit::YamlFile>().unwrap();
let document = file.document().unwrap();
assert!(!set_yaml_edit_nested_value(
&document, "plugins", "enabled", true
));
assert!(!set_yaml_edit_nested_value(
&document,
"routing",
"strategy",
"fill-first".to_string()
));
assert_eq!(file.to_string(), unsupported);
}
#[test]
fn yaml_edit_runtime_patch_recreates_api_keys_after_delete_all() {
let input = "# Client authentication\napi-keys:\n - old-key\nplugins:\n enabled: true\n";
let cleared = patch_core_api_keys_yaml(input, &[]).unwrap();
let rendered = patch_core_api_keys_yaml(&cleared, &["restored-key".to_string()]).unwrap();
assert!(rendered.contains("# Client authentication"), "{rendered}");
let settings =
core_config_settings_from_value(&serde_norway::from_str(&rendered).unwrap()).unwrap();
assert_eq!(settings.api_keys, vec!["restored-key"]);
}
#[test]
fn yaml_edit_runtime_patch_adds_api_keys_to_core_style_config() {
let input = "host: 127.0.0.1\nremote-management:\n# nested setting comment\n allow-remote: false\nauth-dir: /tmp/oauth\n# API keys for authentication\n# Enable debug logging\ndebug: false\n\n# Optional payload configuration\n# payload:\n# filter:\n# - models:\n# - name: \"gemini-2.5-pro\"\n# params:\n# - \"generationConfig.responseJsonSchema\"\n";
let rendered =
patch_core_api_keys_yaml(input, &["new-key".to_string(), "backup-key".to_string()])
.unwrap();
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
let settings = core_config_settings_from_value(&parsed).unwrap();
assert_eq!(settings.api_keys, vec!["new-key", "backup-key"]);
assert!(rendered.contains("# Optional payload configuration"));
assert!(rendered.contains("generationConfig.responseJsonSchema"));
assert!(
rendered.find("auth-dir: /tmp/oauth").unwrap() < rendered.find("api-keys:").unwrap()
);
assert!(rendered.find("api-keys:").unwrap() < rendered.find("debug: false").unwrap());
}
#[test]
fn yaml_edit_runtime_patch_updates_existing_real_core_config() {
let input = "host: 0.0.0.0\nremote-management:\n# nested comment\n allow-remote: false\nauth-dir: /tmp/oauth\n# API keys for authentication\napi-keys:\n - '123456'\n# Enable debug logging\ndebug: false\n\n# payload:\n# filter:\n# - models:\n# - name: gemini\n";
let rendered =
patch_core_api_keys_yaml(input, &[DEFAULT_API_KEY.to_string(), "new-key".to_string()])
.unwrap();
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
assert_eq!(
core_config_settings_from_value(&parsed).unwrap().api_keys,
vec![DEFAULT_API_KEY, "new-key"]
);
}
#[test]
fn runtime_api_key_patch_replaces_indentationless_core_sequence() {
let input = "host: 0.0.0.0\nport: 8317\nauth-dir: /tmp/oauth\napi-keys:\n- '123456'\ndebug: false\n";
let rendered = patch_core_api_keys_yaml(input, &[DEFAULT_API_KEY.to_string()])
.unwrap_or_else(|error| panic!("patch failed: {error}"));
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
assert_eq!(
core_config_settings_from_value(&parsed).unwrap().api_keys,
vec![DEFAULT_API_KEY]
);
assert_eq!(rendered.matches("- '123456'").count(), 1, "{rendered}");
assert!(rendered.contains("debug: false"), "{rendered}");
}
#[test]
fn yaml_edit_runtime_patch_migrates_legacy_api_key_entries() {
let input = "auth:\n providers:\n config-api-key:\n api-key-entries:\n - api-key: first-key\n - key: second-key\nplugins:\n enabled: false\n";
let rendered = patch_core_api_keys_yaml(input, &["migrated-key".to_string()]).unwrap();
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered).unwrap();
let settings = core_config_settings_from_value(&parsed).unwrap();
assert_eq!(settings.api_keys, vec!["migrated-key"]);
assert!(
nested_yaml_value(
parsed.as_mapping().unwrap(),
&["auth", "providers", "config-api-key", "api-key-entries"]
)
.is_none(),
"{rendered}"
);
}
#[test]
fn core_config_reads_legacy_api_key_entries() {
let input = "auth:\n providers:\n config-api-key:\n api-key-entries:\n - api-key: first-key\n - key: second-key\nplugins:\n enabled: false\nrouting:\n strategy: round-robin\n";
let document = serde_norway::from_str::<serde_norway::Value>(input).unwrap();
let settings = core_config_settings_from_value(&document).unwrap();
assert_eq!(settings.api_keys, vec!["first-key", "second-key"]);
assert!(!settings.plugins_enabled);
assert_eq!(settings.routing_strategy, "round-robin");
}
#[test]
fn core_config_validates_keys_and_routing_strategy() {
assert!(validate_core_api_key("sk-valid_123").is_ok());
assert!(validate_core_api_key("").is_err());
assert!(validate_core_api_key("contains space").is_err());
assert!(validate_routing_strategy("round-robin").is_ok());
assert!(validate_routing_strategy("fill-first").is_ok());
assert!(validate_routing_strategy("random").is_err());
}
#[test]
fn unchanged_yaml_is_not_written_again() {
let path = std::env::temp_dir().join(format!(
"cpa-gui-unchanged-yaml-{}-{}.yaml",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let content = "host: 127.0.0.1\nport: 8317\n";
fs::write(&path, content).unwrap();
assert!(!write_yaml_if_changed(&path, content).unwrap());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
fs::remove_file(path).unwrap();
}
#[test]
fn startup_merge_uses_template_and_preserves_current_values() {
let template = "# Current release template\nhost: \"\" # template bind address\nport: 8317\n\n# Client authentication\napi-keys:\n - template-key\n\n# Plugin runtime\nplugins:\n enabled: false # plugin switch\n\n# Credential routing\nrouting:\n strategy: round-robin # routing switch\n\n# New release option\nnew-option: true\nnested:\n # Nested template comment\n keep: template\n added: from-template\nlist:\n - template-item\n";
let current = "host: 127.0.0.1\nport: 9000\nnested:\n keep: current\n current-only: retained\nlist:\n - current-a\n - current-b\nextra: true\n";
let config = GuiConfigFile {
locale: "zh-CN".to_string(),
port: 9527,
allow_lan: true,
run_on_startup: false,
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
api_keys: vec![
built_in_api_key_entry(),
GuiApiKeyEntry {
key: "gui-key".to_string(),
remark: "测试密钥".to_string(),
},
],
management_secret_key: String::new(),
plugins_enabled: true,
routing_strategy: "fill-first".to_string(),
};
let merged = merge_core_config_yaml(template, Some(current), &config).unwrap();
assert!(merged.contains("# Current release template"));
assert!(merged.contains("# template bind address"), "{merged}");
assert!(merged.contains("# New release option"));
assert!(merged.contains("# Nested template comment"));
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
assert_eq!(
document["host"],
serde_norway::Value::String("0.0.0.0".to_string())
);
assert_eq!(document["port"], serde_norway::to_value(9527_u16).unwrap());
assert_eq!(document["api-keys"][0], DEFAULT_API_KEY);
assert_eq!(document["api-keys"][1], "gui-key");
assert_eq!(document["plugins"]["enabled"], true, "{merged}");
assert_eq!(document["routing"]["strategy"], "fill-first");
assert_eq!(document["usage-statistics-enabled"], true);
assert_eq!(document["new-option"], serde_norway::Value::Bool(true));
assert_eq!(
document["nested"]["keep"],
serde_norway::Value::String("current".to_string())
);
assert_eq!(
document["nested"]["added"],
serde_norway::Value::String("from-template".to_string())
);
assert_eq!(
document["nested"]["current-only"],
serde_norway::Value::String("retained".to_string())
);
assert_eq!(document["extra"], serde_norway::Value::Bool(true));
assert_eq!(
document["list"],
serde_norway::Value::Sequence(vec![
serde_norway::Value::String("current-a".to_string()),
serde_norway::Value::String("current-b".to_string()),
])
);
}
#[test]
fn startup_merge_without_current_config_uses_gui_defaults() {
let template = "# Template\nhost: \"\"\nport: 9000\napi-keys:\n - template-key\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\ndebug: false\n";
let merged = merge_core_config_yaml(template, None, &GuiConfigFile::default()).unwrap();
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
assert!(merged.contains("# Template"));
assert_eq!(
document["host"],
serde_norway::Value::String("127.0.0.1".to_string())
);
assert_eq!(document["port"], serde_norway::to_value(8317_u16).unwrap());
assert_eq!(document["debug"], serde_norway::Value::Bool(false));
assert_eq!(document["api-keys"][0], DEFAULT_API_KEY, "{merged}");
assert_eq!(document["plugins"]["enabled"], false);
assert_eq!(document["routing"]["strategy"], "round-robin");
assert_eq!(document["usage-statistics-enabled"], true);
}
#[test]
fn startup_merge_can_shrink_template_api_key_sequence() {
let template = "host: \"\"\nport: 8317\nremote-management:\n secret-key: \"\"\nauth-dir: ~/.cli-proxy-api\napi-keys:\n - template-one\n - template-two\n - template-three\ndebug: false\nplugins:\n enabled: false\nrouting:\n strategy: round-robin\n";
let current = "host: 127.0.0.1\nport: 8317\nremote-management:\n secret-key: hashed\nauth-dir: C:/oauth\napi-keys:\n - '123456'\ndebug: false\nplugins:\n enabled: false\nrouting:\n strategy: round-robin\n";
let merged =
merge_core_config_yaml(template, Some(current), &GuiConfigFile::default()).unwrap();
let document = serde_norway::from_str::<serde_norway::Value>(&merged)
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{merged}"));
assert_eq!(document["api-keys"][0], DEFAULT_API_KEY);
assert_eq!(document["api-keys"].as_sequence().unwrap().len(), 1);
assert_eq!(document["debug"], false);
}
#[test]
fn release_atom_parser_reads_first_release_tag() {
let xml = r#"
<feed>
<entry>
<link href="https://github.com/router-for-me/CLIProxyAPI/releases/tag/v7.2.80"/>
<title>v7.2.80</title>
</entry>
<entry><title>v7.2.79</title></entry>
</feed>
"#;
assert_eq!(release_tag_from_atom(xml).as_deref(), Some("v7.2.80"));
}
#[test]
fn app_update_comparison_uses_semantic_versions() {
assert!(is_app_update_available("v0.1.9", "v0.2.0").unwrap());
assert!(is_app_update_available("v0.2.0-beta.1", "v0.2.0").unwrap());
assert!(!is_app_update_available("v0.2.0", "v0.2.0").unwrap());
assert!(!is_app_update_available("v0.2.0", "v0.1.9").unwrap());
}
#[test]
fn synthetic_release_uses_official_asset_names_and_urls() {
let release = release_from_tag("7.2.80");
let platform = CorePlatform {
os: "linux".to_string(),
arch: "x86_64".to_string(),
asset_os: "linux".to_string(),
asset_arch: "amd64".to_string(),
archive_kind: "tar.gz".to_string(),
};
let asset = select_release_asset(&release, &platform).unwrap();
assert_eq!(release.tag_name, "v7.2.80");
assert_eq!(asset.name, "CLIProxyAPI_7.2.80_linux_amd64.tar.gz");
assert_eq!(
asset.browser_download_url,
"https://github.com/router-for-me/CLIProxyAPI/releases/download/v7.2.80/CLIProxyAPI_7.2.80_linux_amd64.tar.gz"
);
}
#[test]
fn release_asset_names_cover_the_six_supported_gui_targets() {
let targets = [
("linux", "amd64", "tar.gz"),
("linux", "aarch64", "tar.gz"),
("darwin", "amd64", "tar.gz"),
("darwin", "aarch64", "tar.gz"),
("windows", "amd64", "zip"),
("windows", "aarch64", "zip"),
];
for (os, arch, archive_kind) in targets {
let platform = CorePlatform {
os: os.to_string(),
arch: arch.to_string(),
asset_os: os.to_string(),
asset_arch: arch.to_string(),
archive_kind: archive_kind.to_string(),
};
assert_eq!(
core_release_asset_name("v7.2.83", &platform),
format!("CLIProxyAPI_7.2.83_{os}_{arch}.{archive_kind}")
);
}
}
#[test]
fn agent_launch_requires_an_active_managed_configuration() {
assert!(
validate_agent_launch_modification(AgentClient::Codex, true, AGENT_PHASE_ACTIVE)
.is_ok()
);
assert!(
validate_agent_launch_modification(AgentClient::Codex, false, AGENT_PHASE_ACTIVE)
.is_err()
);
assert!(validate_agent_launch_modification(
AgentClient::Codex,
true,
AGENT_MODIFICATION_STATE_CONFLICT,
)
.is_err());
}
#[test]
fn replacing_a_core_preserves_only_regular_bundled_assets() {
let root = agent_test_home("bundled-assets");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
fs::write(
source.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz"),
b"archive",
)
.unwrap();
fs::write(
source.join("CLIProxyAPI_7.2.83_linux_amd64_no-plugin.tar.gz"),
b"portable",
)
.unwrap();
fs::write(source.join(CORE_CHECKSUMS_FILE), b"checksums").unwrap();
preserve_bundled_core_assets(&source, &target).unwrap();
assert!(target
.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz")
.is_file());
assert!(!target
.join("CLIProxyAPI_7.2.83_linux_amd64_no-plugin.tar.gz")
.exists());
assert!(target.join(CORE_CHECKSUMS_FILE).is_file());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn replacing_a_core_preserves_the_existing_configuration_bytes() {
let root = agent_test_home("core-config-preserve");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
let original = b"host: 127.0.0.1\n# keep this comment\ndebug: true\n";
fs::write(source.join(CORE_CONFIG_FILE), original).unwrap();
preserve_core_runtime_files(&source, &target).unwrap();
assert_eq!(fs::read(target.join(CORE_CONFIG_FILE)).unwrap(), original);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn bundled_core_locations_include_macos_app_resources() {
let contents_dir = agent_test_home("bundled-macos-resources")
.join("EasyCLIProxyAPI.app")
.join("Contents");
let executable_dir = contents_dir.join("MacOS");
let base_dir = agent_test_home("bundled-macos-data");
let resource_location = (
contents_dir.join("Resources").join(CORE_VERSION_FILE),
contents_dir.join("Resources").join("cpa-core"),
);
assert_eq!(
macos_app_resources_dir(&executable_dir),
Some(contents_dir.join("Resources"))
);
assert!(bundled_core_locations(&base_dir, &executable_dir).contains(&resource_location));
}
#[test]
fn source_project_root_is_detected_from_the_portable_development_directory() {
let root = agent_test_home("bundled-source-root");
fs::create_dir_all(root.join("src-tauri")).unwrap();
fs::create_dir_all(root.join("bin-work")).unwrap();
fs::write(root.join("package.json"), b"{}").unwrap();
assert_eq!(
source_project_root(&root.join("bin-work")),
Some(root.clone())
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn selected_source_archive_and_checksums_are_copied_into_the_installation() {
let root = agent_test_home("selected-bundled-asset");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
let archive = source.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz");
fs::write(&archive, b"archive").unwrap();
fs::write(source.join(CORE_CHECKSUMS_FILE), b"checksums").unwrap();
preserve_selected_bundled_core_asset(&archive, &target).unwrap();
assert_eq!(
fs::read(target.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz")).unwrap(),
b"archive"
);
assert_eq!(
fs::read(target.join(CORE_CHECKSUMS_FILE)).unwrap(),
b"checksums"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn release_page_assets_parse_download_links_and_sha256() {
let html = r#"
<li><a href="/router-for-me/CLIProxyAPI/releases/download/v1.2.3/checksums.txt">checksums.txt</a></li>
<li><a href="/router-for-me/CLIProxyAPI/releases/download/v1.2.3/CLIProxyAPI_1.2.3_linux_amd64.tar.gz">asset</a>
<span>sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef</span>
</li>
"#;
let assets = parse_release_assets(html);
assert_eq!(assets.len(), 2);
assert_eq!(assets[1].name, "CLIProxyAPI_1.2.3_linux_amd64.tar.gz");
assert_eq!(
assets[1].digest.as_deref(),
Some("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
);
assert!(assets[1]
.browser_download_url
.ends_with("/releases/download/v1.2.3/CLIProxyAPI_1.2.3_linux_amd64.tar.gz"));
}
}