mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Merge branch 'main' into feat/smart-url-path-detection
# Conflicts: # src/App.tsx # src/hooks/useProviderActions.ts # src/i18n/locales/en.json # src/i18n/locales/ja.json # src/i18n/locales/zh.json
This commit is contained in:
Generated
+63
@@ -714,6 +714,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hyper",
|
||||
"indexmap 2.11.4",
|
||||
"json5",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -727,6 +728,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
@@ -746,6 +748,7 @@ dependencies = [
|
||||
"tower-http 0.5.2",
|
||||
"url",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
@@ -2519,6 +2522,17 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json5"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
@@ -3403,6 +3417,49 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.8.0"
|
||||
@@ -5886,6 +5943,12 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
|
||||
@@ -61,10 +61,15 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
json5 = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ impl McpApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ impl McpApps {
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +85,7 @@ impl SkillApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +96,7 @@ impl SkillApps {
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +226,9 @@ pub struct McpRoot {
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub openclaw: McpConfig,
|
||||
}
|
||||
|
||||
impl Default for McpRoot {
|
||||
@@ -234,6 +241,7 @@ impl Default for McpRoot {
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
openclaw: McpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,6 +264,8 @@ pub struct PromptRoot {
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub openclaw: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
@@ -271,6 +281,7 @@ pub enum AppType {
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -280,15 +291,16 @@ impl AppType {
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this app uses additive mode
|
||||
///
|
||||
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
|
||||
pub fn is_additive_mode(&self) -> bool {
|
||||
matches!(self, AppType::OpenCode)
|
||||
matches!(self, AppType::OpenCode | AppType::OpenClaw)
|
||||
}
|
||||
|
||||
/// Return an iterator over all app types
|
||||
@@ -298,6 +310,7 @@ impl AppType {
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
@@ -313,10 +326,11 @@ impl FromStr for AppType {
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"openclaw" => Ok(AppType::OpenClaw),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -336,6 +350,9 @@ pub struct CommonConfigSnippets {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openclaw: Option<String>,
|
||||
}
|
||||
|
||||
impl CommonConfigSnippets {
|
||||
@@ -346,6 +363,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +374,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,6 +415,7 @@ impl Default for MultiAppConfig {
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("openclaw".to_string(), ProviderManager::default());
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -555,6 +575,7 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
AppType::OpenClaw => &self.mcp.openclaw,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +586,7 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
AppType::OpenClaw => &mut self.mcp.openclaw,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +601,7 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -599,6 +622,7 @@ impl MultiAppConfig {
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
|| !self.prompts.openclaw.prompts.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -611,6 +635,7 @@ impl MultiAppConfig {
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
] {
|
||||
// 复用已有的单应用导入逻辑
|
||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||
@@ -681,6 +706,7 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
|
||||
};
|
||||
|
||||
prompts.insert(id, prompt);
|
||||
@@ -709,12 +735,18 @@ impl MultiAppConfig {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// 收集所有应用的 MCP
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
let old_servers = match app {
|
||||
AppType::Claude => &self.mcp.claude.servers,
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
|
||||
};
|
||||
|
||||
for (id, entry) in old_servers {
|
||||
|
||||
@@ -59,6 +59,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
let config_path = crate::openclaw_config::get_openclaw_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::openclaw_config::get_openclaw_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +83,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
};
|
||||
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
@@ -86,6 +96,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
};
|
||||
|
||||
if !config_dir.exists() {
|
||||
|
||||
@@ -5,10 +5,15 @@ use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
use crate::commands::sync_support::{
|
||||
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::ProviderService;
|
||||
use crate::store::AppState;
|
||||
|
||||
// ─── File import/export ──────────────────────────────────────
|
||||
|
||||
/// 导出数据库为 SQL 备份
|
||||
#[tauri::command]
|
||||
pub async fn export_config_to_file(
|
||||
@@ -37,27 +42,15 @@ pub async fn import_config_from_file(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_state = db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path_buf = PathBuf::from(&filePath);
|
||||
let backup_id = db.import_sql(&path_buf)?;
|
||||
|
||||
// 导入后同步当前供应商到各自的 live 配置
|
||||
let app_state = AppState::new(db_for_state);
|
||||
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
|
||||
log::warn!("导入后同步 live 配置失败: {err}");
|
||||
let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync)));
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[Import] post-import sync warning: {msg}");
|
||||
}
|
||||
|
||||
// 重新加载设置到内存缓存,确保导入的设置生效
|
||||
if let Err(err) = crate::settings::reload_settings() {
|
||||
log::warn!("导入后重载设置失败: {err}");
|
||||
}
|
||||
|
||||
Ok::<_, AppError>(json!({
|
||||
"success": true,
|
||||
"message": "SQL imported successfully",
|
||||
"backupId": backup_id
|
||||
}))
|
||||
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("导入配置失败: {e}"))?
|
||||
@@ -80,6 +73,8 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
}
|
||||
|
||||
// ─── File dialogs ────────────────────────────────────────────
|
||||
|
||||
/// 保存文件对话框
|
||||
#[tauri::command]
|
||||
pub async fn save_file_dialog<R: tauri::Runtime>(
|
||||
|
||||
+183
-35
@@ -313,6 +313,79 @@ fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<St
|
||||
)
|
||||
}
|
||||
|
||||
fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
|
||||
if path.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_env_single_dir(paths: &mut Vec<std::path::PathBuf>, value: Option<std::ffi::OsString>) {
|
||||
if let Some(raw) = value {
|
||||
push_unique_path(paths, std::path::PathBuf::from(raw));
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_from_path_list(
|
||||
paths: &mut Vec<std::path::PathBuf>,
|
||||
value: Option<std::ffi::OsString>,
|
||||
suffix: Option<&str>,
|
||||
) {
|
||||
if let Some(raw) = value {
|
||||
for p in std::env::split_paths(&raw) {
|
||||
let dir = match suffix {
|
||||
Some(s) => p.join(s),
|
||||
None => p,
|
||||
};
|
||||
push_unique_path(paths, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README):
|
||||
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
|
||||
/// 额外扫描 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
|
||||
fn opencode_extra_search_paths(
|
||||
home: &Path,
|
||||
opencode_install_dir: Option<std::ffi::OsString>,
|
||||
xdg_bin_dir: Option<std::ffi::OsString>,
|
||||
gopath: Option<std::ffi::OsString>,
|
||||
) -> Vec<std::path::PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
push_env_single_dir(&mut paths, opencode_install_dir);
|
||||
push_env_single_dir(&mut paths, xdg_bin_dir);
|
||||
|
||||
if !home.as_os_str().is_empty() {
|
||||
push_unique_path(&mut paths, home.join("bin"));
|
||||
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
|
||||
push_unique_path(&mut paths, home.join("go").join("bin"));
|
||||
}
|
||||
|
||||
extend_from_path_list(&mut paths, gopath, Some("bin"));
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
vec![
|
||||
dir.join(format!("{tool}.cmd")),
|
||||
dir.join(format!("{tool}.exe")),
|
||||
dir.join(tool),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
vec![dir.join(tool)]
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描常见路径查找 CLI
|
||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
@@ -320,88 +393,99 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
|
||||
// 常见的安装路径(原生安装优先)
|
||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
||||
home.join(".local/bin"), // Native install (official recommended)
|
||||
home.join(".npm-global/bin"),
|
||||
home.join("n/bin"), // n version manager
|
||||
home.join(".volta/bin"), // Volta package manager
|
||||
];
|
||||
let mut search_paths: Vec<std::path::PathBuf> = Vec::new();
|
||||
if !home.as_os_str().is_empty() {
|
||||
push_unique_path(&mut search_paths, home.join(".local/bin"));
|
||||
push_unique_path(&mut search_paths, home.join(".npm-global/bin"));
|
||||
push_unique_path(&mut search_paths, home.join("n/bin"));
|
||||
push_unique_path(&mut search_paths, home.join(".volta/bin"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
|
||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
std::path::PathBuf::from("/opt/homebrew/bin"),
|
||||
);
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
std::path::PathBuf::from("/usr/local/bin"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||
search_paths.push(std::path::PathBuf::from("/usr/bin"));
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
std::path::PathBuf::from("/usr/local/bin"),
|
||||
);
|
||||
push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(appdata) = dirs::data_dir() {
|
||||
search_paths.push(appdata.join("npm"));
|
||||
push_unique_path(&mut search_paths, appdata.join("npm"));
|
||||
}
|
||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
std::path::PathBuf::from("C:\\Program Files\\nodejs"),
|
||||
);
|
||||
}
|
||||
|
||||
// 添加 fnm 路径支持
|
||||
let fnm_base = home.join(".local/state/fnm_multishells");
|
||||
if fnm_base.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
|
||||
for entry in entries.flatten() {
|
||||
let bin_path = entry.path().join("bin");
|
||||
if bin_path.exists() {
|
||||
search_paths.push(bin_path);
|
||||
push_unique_path(&mut search_paths, bin_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描 nvm 目录下的所有 node 版本
|
||||
let nvm_base = home.join(".nvm/versions/node");
|
||||
if nvm_base.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
|
||||
for entry in entries.flatten() {
|
||||
let bin_path = entry.path().join("bin");
|
||||
if bin_path.exists() {
|
||||
search_paths.push(bin_path);
|
||||
push_unique_path(&mut search_paths, bin_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 Go 路径支持 (opencode 使用 go install 安装)
|
||||
if tool == "opencode" {
|
||||
search_paths.push(home.join("go/bin")); // go install 默认路径
|
||||
if let Ok(gopath) = std::env::var("GOPATH") {
|
||||
search_paths.push(std::path::PathBuf::from(gopath).join("bin"));
|
||||
let extra_paths = opencode_extra_search_paths(
|
||||
&home,
|
||||
std::env::var_os("OPENCODE_INSTALL_DIR"),
|
||||
std::env::var_os("XDG_BIN_DIR"),
|
||||
std::env::var_os("GOPATH"),
|
||||
);
|
||||
|
||||
for path in extra_paths {
|
||||
push_unique_path(&mut search_paths, path);
|
||||
}
|
||||
}
|
||||
|
||||
// 在每个路径中查找工具
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
for path in &search_paths {
|
||||
let tool_path = if cfg!(target_os = "windows") {
|
||||
path.join(format!("{tool}.cmd"))
|
||||
} else {
|
||||
path.join(tool)
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let new_path = format!("{};{}", path.display(), current_path);
|
||||
|
||||
if tool_path.exists() {
|
||||
// 构建 PATH 环境变量,确保 node 可被找到
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let new_path = format!("{}:{}", path.display(), current_path);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let new_path = format!("{};{}", path.display(), current_path);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let new_path = format!("{}:{}", path.display(), current_path);
|
||||
for tool_path in tool_executable_candidates(tool, path) {
|
||||
if !tool_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||
.env("PATH", &new_path)
|
||||
@@ -971,3 +1055,67 @@ pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<()
|
||||
|
||||
window.set_theme(tauri_theme).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let install_dir = Some(std::ffi::OsString::from("/custom/opencode/bin"));
|
||||
let xdg_bin_dir = Some(std::ffi::OsString::from("/xdg/bin"));
|
||||
let gopath =
|
||||
std::env::join_paths([PathBuf::from("/go/path1"), PathBuf::from("/go/path2")]).ok();
|
||||
|
||||
let paths = opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath);
|
||||
|
||||
assert_eq!(paths[0], PathBuf::from("/custom/opencode/bin"));
|
||||
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_extra_search_paths_deduplicates_repeated_entries() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let same_dir = Some(std::ffi::OsString::from("/same/path"));
|
||||
|
||||
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None);
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
.filter(|path| **path == PathBuf::from("/same/path"))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
|
||||
let dir = PathBuf::from("/usr/local/bin");
|
||||
let candidates = tool_executable_candidates("opencode", &dir);
|
||||
|
||||
assert_eq!(candidates, vec![PathBuf::from("/usr/local/bin/opencode")]);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn tool_executable_candidates_windows_includes_cmd_exe_and_plain_name() {
|
||||
let dir = PathBuf::from("C:\\tools");
|
||||
let candidates = tool_executable_candidates("opencode", &dir);
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
vec![
|
||||
PathBuf::from("C:\\tools\\opencode.cmd"),
|
||||
PathBuf::from("C:\\tools\\opencode.exe"),
|
||||
PathBuf::from("C:\\tools\\opencode"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod omo;
|
||||
mod openclaw;
|
||||
mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
@@ -17,7 +18,10 @@ mod session_manager;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod sync_support;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
|
||||
pub use config::*;
|
||||
pub use deeplink::*;
|
||||
@@ -28,6 +32,7 @@ pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use omo::*;
|
||||
pub use openclaw::*;
|
||||
pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
@@ -37,3 +42,5 @@ pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
|
||||
use crate::openclaw_config;
|
||||
use crate::store::AppState;
|
||||
|
||||
// ============================================================================
|
||||
// OpenClaw Provider Commands (migrated from provider.rs)
|
||||
// ============================================================================
|
||||
|
||||
/// Import providers from OpenClaw live config to database.
|
||||
///
|
||||
/// OpenClaw uses additive mode — users may already have providers
|
||||
/// configured in openclaw.json.
|
||||
#[tauri::command]
|
||||
pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
crate::services::provider::import_openclaw_providers_from_live(state.inner())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get provider IDs in the OpenClaw live config.
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
openclaw_config::get_providers()
|
||||
.map(|providers| providers.keys().cloned().collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agents Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Get OpenClaw default model config (agents.defaults.model)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDefaultModel>, String>
|
||||
{
|
||||
openclaw_config::get_default_model().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set OpenClaw default model config (agents.defaults.model)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_default_model(
|
||||
model: openclaw_config::OpenClawDefaultModel,
|
||||
) -> Result<(), String> {
|
||||
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get OpenClaw model catalog/allowlist (agents.defaults.models)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_model_catalog(
|
||||
) -> Result<Option<HashMap<String, openclaw_config::OpenClawModelCatalogEntry>>, String> {
|
||||
openclaw_config::get_model_catalog().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set OpenClaw model catalog/allowlist (agents.defaults.models)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_model_catalog(
|
||||
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
|
||||
) -> Result<(), String> {
|
||||
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get full agents.defaults config (all fields)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_agents_defaults(
|
||||
) -> Result<Option<openclaw_config::OpenClawAgentsDefaults>, String> {
|
||||
openclaw_config::get_agents_defaults().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set full agents.defaults config (all fields)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_agents_defaults(
|
||||
defaults: openclaw_config::OpenClawAgentsDefaults,
|
||||
) -> Result<(), String> {
|
||||
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Env Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Get OpenClaw env config (env section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String> {
|
||||
openclaw_config::get_env_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set OpenClaw env config (env section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
|
||||
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tools Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Get OpenClaw tools config (tools section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, String> {
|
||||
openclaw_config::get_tools_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set OpenClaw tools config (tools section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
|
||||
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -318,3 +318,7 @@ pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
.map(|providers| providers.keys().cloned().collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
|
||||
// ============================================================================
|
||||
|
||||
@@ -2,16 +2,28 @@
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
fn merge_settings_for_save(
|
||||
mut incoming: crate::settings::AppSettings,
|
||||
existing: &crate::settings::AppSettings,
|
||||
) -> crate::settings::AppSettings {
|
||||
if incoming.webdav_sync.is_none() {
|
||||
incoming.webdav_sync = existing.webdav_sync.clone();
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
/// 获取设置
|
||||
#[tauri::command]
|
||||
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||
Ok(crate::settings::get_settings())
|
||||
Ok(crate::settings::get_settings_for_frontend())
|
||||
}
|
||||
|
||||
/// 保存设置
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
||||
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
|
||||
let existing = crate::settings::get_settings();
|
||||
let merged = merge_settings_for_save(settings, &existing);
|
||||
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -54,6 +66,58 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::merge_settings_for_save;
|
||||
use crate::settings::{AppSettings, WebDavSyncSettings};
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let incoming = AppSettings::default();
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert!(merged.webdav_sync.is_some());
|
||||
assert_eq!(
|
||||
merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),
|
||||
Some("https://dav.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_keep_incoming_webdav_when_present() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.old.example.com".to_string(),
|
||||
username: "old".to_string(),
|
||||
password: "old-pass".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.new.example.com".to_string(),
|
||||
username: "new".to_string(),
|
||||
password: "new-pass".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),
|
||||
Some("https://dav.new.example.com")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取开机自启状态
|
||||
#[tauri::command]
|
||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::ProviderService;
|
||||
use crate::settings;
|
||||
use crate::store::AppState;
|
||||
|
||||
pub(crate) fn run_post_import_sync(db: Arc<Database>) -> Result<(), AppError> {
|
||||
let app_state = AppState::new(db);
|
||||
ProviderService::sync_current_to_live(&app_state)?;
|
||||
settings::reload_settings()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn post_sync_warning<E: std::fmt::Display>(err: E) -> String {
|
||||
AppError::localized(
|
||||
"sync.post_operation_sync_failed",
|
||||
format!("后置同步状态失败: {err}"),
|
||||
format!("Post-operation synchronization failed: {err}"),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn post_sync_warning_from_result(
|
||||
result: Result<Result<(), AppError>, String>,
|
||||
) -> Option<String> {
|
||||
match result {
|
||||
Ok(Ok(())) => None,
|
||||
Ok(Err(err)) => Some(post_sync_warning(err)),
|
||||
Err(err) => Some(post_sync_warning(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_warning(mut value: Value, warning: Option<String>) -> Value {
|
||||
if let Some(message) = warning {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("warning".to_string(), Value::String(message));
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option<String>) -> Value {
|
||||
attach_warning(
|
||||
json!({
|
||||
"success": true,
|
||||
"message": "SQL imported successfully",
|
||||
"backupId": backup_id
|
||||
}),
|
||||
warning,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{attach_warning, post_sync_warning_from_result};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn post_sync_warning_from_result_returns_none_on_success() {
|
||||
let warning = post_sync_warning_from_result(Ok(Ok(())));
|
||||
assert!(warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_sync_warning_from_result_returns_some_on_sync_error() {
|
||||
let warning =
|
||||
post_sync_warning_from_result(Ok(Err(crate::error::AppError::Config("boom".into()))));
|
||||
assert!(warning.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_sync_warning_from_result_returns_some_on_join_error() {
|
||||
let handle = tokio::spawn(async move {
|
||||
panic!("forced join error");
|
||||
});
|
||||
let join_err = handle.await.expect_err("task should panic");
|
||||
let warning = post_sync_warning_from_result(Err(join_err.to_string()));
|
||||
assert!(warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_warning_adds_warning_without_dropping_existing_fields() {
|
||||
let payload = json!({ "status": "downloaded" });
|
||||
let updated = attach_warning(payload, Some("post sync warning".to_string()));
|
||||
assert_eq!(
|
||||
updated.get("status").and_then(|v| v.as_str()),
|
||||
Some("downloaded")
|
||||
);
|
||||
assert_eq!(
|
||||
updated.get("warning").and_then(|v| v.as_str()),
|
||||
Some("post sync warning")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::future::Future;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::sync_support::{
|
||||
attach_warning, post_sync_warning_from_result, run_post_import_sync,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::services::webdav_sync as webdav_sync_service;
|
||||
use crate::settings::{self, WebDavSyncSettings};
|
||||
use crate::store::AppState;
|
||||
|
||||
fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) {
|
||||
settings.status.last_error = Some(error.to_string());
|
||||
let _ = settings::update_webdav_sync_status(settings.status.clone());
|
||||
}
|
||||
|
||||
fn webdav_not_configured_error() -> String {
|
||||
AppError::localized(
|
||||
"webdav.sync.not_configured",
|
||||
"未配置 WebDAV 同步",
|
||||
"WebDAV sync is not configured.",
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn webdav_sync_disabled_error() -> String {
|
||||
AppError::localized(
|
||||
"webdav.sync.disabled",
|
||||
"WebDAV 同步未启用",
|
||||
"WebDAV sync is disabled.",
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn require_enabled_webdav_settings() -> Result<WebDavSyncSettings, String> {
|
||||
let settings = settings::get_webdav_sync_settings().ok_or_else(webdav_not_configured_error)?;
|
||||
if !settings.enabled {
|
||||
return Err(webdav_sync_disabled_error());
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn resolve_password_for_request(
|
||||
mut incoming: WebDavSyncSettings,
|
||||
existing: Option<WebDavSyncSettings>,
|
||||
preserve_empty_password: bool,
|
||||
) -> WebDavSyncSettings {
|
||||
if let Some(existing_settings) = existing {
|
||||
if preserve_empty_password && incoming.password.is_empty() {
|
||||
incoming.password = existing_settings.password;
|
||||
}
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
async fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
let result = {
|
||||
let _guard = webdav_sync_mutex().lock().await;
|
||||
operation.await
|
||||
};
|
||||
result
|
||||
}
|
||||
|
||||
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(&AppError),
|
||||
{
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
on_error(&err);
|
||||
Err(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_test_connection(
|
||||
settings: WebDavSyncSettings,
|
||||
#[allow(non_snake_case)] preserveEmptyPassword: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let preserve_empty = preserveEmptyPassword.unwrap_or(true);
|
||||
let resolved = resolve_password_for_request(
|
||||
settings,
|
||||
settings::get_webdav_sync_settings(),
|
||||
preserve_empty,
|
||||
);
|
||||
webdav_sync_service::check_connection(&resolved)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"message": "WebDAV connection ok"
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let mut settings = require_enabled_webdav_settings()?;
|
||||
|
||||
let result = run_with_webdav_lock(webdav_sync_service::upload(&db, &mut settings)).await;
|
||||
map_sync_result(result, |error| persist_sync_error(&mut settings, error))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
let mut settings = require_enabled_webdav_settings()?;
|
||||
|
||||
let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;
|
||||
let mut result = map_sync_result(sync_result, |error| {
|
||||
persist_sync_error(&mut settings, error)
|
||||
})?;
|
||||
|
||||
// Post-download sync is best-effort: snapshot restore has already succeeded.
|
||||
let warning = post_sync_warning_from_result(
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[WebDAV] post-download sync warning: {msg}");
|
||||
}
|
||||
result = attach_warning(result, warning);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_save_settings(
|
||||
settings: WebDavSyncSettings,
|
||||
#[allow(non_snake_case)] passwordTouched: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let password_touched = passwordTouched.unwrap_or(false);
|
||||
let existing = settings::get_webdav_sync_settings();
|
||||
let mut sync_settings =
|
||||
resolve_password_for_request(settings, existing.clone(), !password_touched);
|
||||
|
||||
// Preserve server-owned fields that the frontend does not manage
|
||||
if let Some(existing_settings) = existing {
|
||||
sync_settings.status = existing_settings.status;
|
||||
}
|
||||
|
||||
sync_settings.normalize();
|
||||
sync_settings.validate().map_err(|e| e.to_string())?;
|
||||
settings::set_webdav_sync_settings(Some(sync_settings)).map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "success": true }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_fetch_remote_info() -> Result<Value, String> {
|
||||
let settings = require_enabled_webdav_settings()?;
|
||||
let info = webdav_sync_service::fetch_remote_info(&settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(info.unwrap_or(json!({ "empty": true })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
map_sync_result, persist_sync_error, require_enabled_webdav_settings,
|
||||
resolve_password_for_request, run_with_webdav_lock, webdav_sync_mutex,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::settings::{AppSettings, WebDavSyncSettings};
|
||||
use serial_test::serial;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn webdav_sync_mutex_is_singleton() {
|
||||
let a = webdav_sync_mutex() as *const _;
|
||||
let b = webdav_sync_mutex() as *const _;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn webdav_sync_mutex_serializes_concurrent_access() {
|
||||
let guard = webdav_sync_mutex().lock().await;
|
||||
let acquired = Arc::new(AtomicBool::new(false));
|
||||
let acquired_bg = Arc::clone(&acquired);
|
||||
|
||||
let waiter = tokio::spawn(async move {
|
||||
let _inner_guard = webdav_sync_mutex().lock().await;
|
||||
acquired_bg.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
assert!(!acquired.load(Ordering::SeqCst));
|
||||
|
||||
drop(guard);
|
||||
tokio::time::timeout(Duration::from_secs(1), waiter)
|
||||
.await
|
||||
.expect("background task should complete after lock release")
|
||||
.expect("background task should not panic");
|
||||
|
||||
assert!(acquired.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn map_sync_result_runs_error_handler_after_lock_release() {
|
||||
let result = run_with_webdav_lock(async {
|
||||
Err::<(), AppError>(AppError::Config("boom".to_string()))
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut lock_released = false;
|
||||
let mapped = map_sync_result(result, |_| {
|
||||
lock_released = webdav_sync_mutex().try_lock().is_ok();
|
||||
});
|
||||
|
||||
assert!(mapped.is_err());
|
||||
assert!(lock_released);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_password_for_request_preserves_existing_when_requested() {
|
||||
let incoming = WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: String::new(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
let existing = Some(WebDavSyncSettings {
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let resolved = resolve_password_for_request(incoming, existing, true);
|
||||
assert_eq!(resolved.password, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_password_for_request_allows_explicit_empty_password() {
|
||||
let incoming = WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: String::new(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
let existing = Some(WebDavSyncSettings {
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let resolved = resolve_password_for_request(incoming, existing, false);
|
||||
assert!(resolved.password.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn persist_sync_error_updates_status_without_overwriting_credentials() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-sync-error-status-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
let mut current = WebDavSyncSettings {
|
||||
enabled: true,
|
||||
base_url: "https://dav.example.com/dav/".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
crate::settings::set_webdav_sync_settings(Some(current.clone()))
|
||||
.expect("seed webdav settings");
|
||||
|
||||
persist_sync_error(
|
||||
&mut current,
|
||||
&crate::error::AppError::Config("boom".to_string()),
|
||||
);
|
||||
|
||||
let after = crate::settings::get_webdav_sync_settings().expect("read webdav settings");
|
||||
assert_eq!(after.base_url, "https://dav.example.com/dav/");
|
||||
assert_eq!(after.username, "alice");
|
||||
assert_eq!(after.password, "secret");
|
||||
assert_eq!(after.remote_root, "cc-switch-sync");
|
||||
assert_eq!(after.profile, "default");
|
||||
assert!(
|
||||
after
|
||||
.status
|
||||
.last_error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("boom"),
|
||||
"status error should be updated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn require_enabled_webdav_settings_rejects_disabled_config() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-sync-enabled-disabled-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings {
|
||||
enabled: false,
|
||||
base_url: "https://dav.example.com/dav/".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}))
|
||||
.expect("seed disabled webdav settings");
|
||||
|
||||
let err = require_enabled_webdav_settings().expect_err("disabled settings should fail");
|
||||
assert!(
|
||||
err.contains("disabled") || err.contains("未启用"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn require_enabled_webdav_settings_returns_settings_when_enabled() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-sync-enabled-ok-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings {
|
||||
enabled: true,
|
||||
base_url: "https://dav.example.com/dav/".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}))
|
||||
.expect("seed enabled webdav settings");
|
||||
|
||||
let settings =
|
||||
require_enabled_webdav_settings().expect("enabled settings should be accepted");
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.base_url, "https://dav.example.com/dav/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::config::write_text_file;
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
|
||||
/// Allowed workspace filenames (whitelist for security)
|
||||
const ALLOWED_FILES: &[&str] = &[
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"IDENTITY.md",
|
||||
"TOOLS.md",
|
||||
"MEMORY.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
"BOOT.md",
|
||||
];
|
||||
|
||||
fn validate_filename(filename: &str) -> Result<(), String> {
|
||||
if !ALLOWED_FILES.contains(&filename) {
|
||||
return Err(format!(
|
||||
"Invalid workspace filename: {filename}. Allowed: {}",
|
||||
ALLOWED_FILES.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read an OpenClaw workspace file content.
|
||||
/// Returns None if the file does not exist.
|
||||
#[tauri::command]
|
||||
pub async fn read_workspace_file(filename: String) -> Result<Option<String>, String> {
|
||||
validate_filename(&filename)?;
|
||||
|
||||
let path = get_openclaw_dir().join("workspace").join(&filename);
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
std::fs::read_to_string(&path)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to read workspace file {filename}: {e}"))
|
||||
}
|
||||
|
||||
/// Write content to an OpenClaw workspace file (atomic write).
|
||||
/// Creates the workspace directory if it does not exist.
|
||||
#[tauri::command]
|
||||
pub async fn write_workspace_file(filename: String, content: String) -> Result<(), String> {
|
||||
validate_filename(&filename)?;
|
||||
|
||||
let workspace_dir = get_openclaw_dir().join("workspace");
|
||||
|
||||
// Ensure workspace directory exists
|
||||
std::fs::create_dir_all(&workspace_dir)
|
||||
.map_err(|e| format!("Failed to create workspace directory: {e}"))?;
|
||||
|
||||
let path = workspace_dir.join(&filename);
|
||||
|
||||
write_text_file(&path, &content)
|
||||
.map_err(|e| format!("Failed to write workspace file {filename}: {e}"))
|
||||
}
|
||||
@@ -16,10 +16,15 @@ use tempfile::NamedTempFile;
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
impl Database {
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
Self::dump_sql(&snapshot)
|
||||
}
|
||||
|
||||
/// 导出为 SQLite 兼容的 SQL 文本
|
||||
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
let dump = Self::dump_sql(&snapshot)?;
|
||||
let dump = self.export_sql_string()?;
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
@@ -38,6 +43,12 @@ impl Database {
|
||||
}
|
||||
|
||||
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
self.import_sql_string(sql_content)
|
||||
}
|
||||
|
||||
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
|
||||
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ impl Database {
|
||||
|
||||
/// 获取整流器配置
|
||||
///
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部开启)
|
||||
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||
match self.get_setting("rectifier_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
|
||||
@@ -297,6 +297,15 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
r#"
|
||||
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
|
||||
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
|
||||
CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
enabled_claude INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_codex INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_gemini INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_opencode INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.expect("seed v4 schema");
|
||||
|
||||
@@ -175,6 +175,10 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
"openclaw" => {
|
||||
// OpenClaw doesn't support MCP, ignore silently
|
||||
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
|
||||
}
|
||||
other => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': {other}"
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw => build_openclaw_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -391,6 +392,35 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
// Build OpenClaw provider config
|
||||
// Format: { baseUrl, apiKey, api, models }
|
||||
let mut config = serde_json::Map::new();
|
||||
|
||||
if !endpoint.is_empty() {
|
||||
config.insert("baseUrl".to_string(), json!(endpoint));
|
||||
}
|
||||
|
||||
if let Some(api_key) = &request.api_key {
|
||||
config.insert("apiKey".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
// Default to OpenAI-compatible API
|
||||
config.insert("api".to_string(), json!("openai-completions"));
|
||||
|
||||
// Build models array
|
||||
if let Some(model) = &request.model {
|
||||
config.insert(
|
||||
"models".to_string(),
|
||||
json!([{ "id": model, "name": model }]),
|
||||
);
|
||||
}
|
||||
|
||||
json!(config)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config Merge Logic
|
||||
// =============================================================================
|
||||
@@ -452,6 +482,10 @@ pub fn parse_and_merge_config(
|
||||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||
// Additive mode apps use JSON config directly; pass through as-is
|
||||
"openclaw" | "opencode" => {
|
||||
merge_additive_config(&mut merged, &config_value)?;
|
||||
}
|
||||
"" => {
|
||||
// No app specified, skip merging
|
||||
return Ok(merged);
|
||||
@@ -623,6 +657,47 @@ fn merge_gemini_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
|
||||
///
|
||||
/// These apps use JSON config directly, so we only extract common fields
|
||||
/// (api_key, endpoint, model) from the config if not already set in URL params.
|
||||
fn merge_additive_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Extract api_key from config if not provided in URL
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(api_key) = config
|
||||
.get("apiKey")
|
||||
.or_else(|| config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract endpoint from config if not provided in URL
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = config
|
||||
.get("baseUrl")
|
||||
.or_else(|| config.get("base_url"))
|
||||
.or_else(|| config.get("options").and_then(|o| o.get("baseURL")))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract base_url from Codex TOML config
|
||||
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
||||
// Try to find base_url in model_providers section
|
||||
|
||||
+51
-1
@@ -13,6 +13,7 @@ mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod openclaw_config;
|
||||
mod opencode_config;
|
||||
mod panic_hook;
|
||||
mod prompt;
|
||||
@@ -500,7 +501,7 @@ pub fn run() {
|
||||
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
|
||||
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
@@ -525,6 +526,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 OpenClaw 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 models.providers 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenClaw providers found to import"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"),
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||
log::info!("MCP table empty, importing from live configurations...");
|
||||
@@ -570,6 +582,8 @@ pub fn run() {
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
crate::app_config::AppType::OpenCode,
|
||||
crate::app_config::AppType::OpenClaw,
|
||||
] {
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
@@ -768,6 +782,21 @@ pub fn run() {
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
});
|
||||
|
||||
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.with_webview(|webview| {
|
||||
use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy};
|
||||
let wk_webview = webview.inner();
|
||||
if let Some(settings) = WebViewExt::settings(&wk_webview) {
|
||||
SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never);
|
||||
log::info!("已禁用 WebKitGTK 硬件加速");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 静默启动:根据设置决定是否显示主窗口
|
||||
let settings = crate::settings::get_settings();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -871,6 +900,11 @@ pub fn run() {
|
||||
// theirs: config import/export and dialogs
|
||||
commands::export_config_to_file,
|
||||
commands::import_config_from_file,
|
||||
commands::webdav_test_connection,
|
||||
commands::webdav_sync_upload,
|
||||
commands::webdav_sync_download,
|
||||
commands::webdav_sync_save_settings,
|
||||
commands::webdav_sync_fetch_remote_info,
|
||||
commands::save_file_dialog,
|
||||
commands::open_file_dialog,
|
||||
commands::open_zip_file_dialog,
|
||||
@@ -975,6 +1009,19 @@ pub fn run() {
|
||||
// OpenCode specific
|
||||
commands::import_opencode_providers_from_live,
|
||||
commands::get_opencode_live_provider_ids,
|
||||
// OpenClaw specific
|
||||
commands::import_openclaw_providers_from_live,
|
||||
commands::get_openclaw_live_provider_ids,
|
||||
commands::get_openclaw_default_model,
|
||||
commands::set_openclaw_default_model,
|
||||
commands::get_openclaw_model_catalog,
|
||||
commands::set_openclaw_model_catalog,
|
||||
commands::get_openclaw_agents_defaults,
|
||||
commands::set_openclaw_agents_defaults,
|
||||
commands::get_openclaw_env,
|
||||
commands::set_openclaw_env,
|
||||
commands::get_openclaw_tools,
|
||||
commands::set_openclaw_tools,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
@@ -987,6 +1034,9 @@ pub fn run() {
|
||||
commands::get_current_omo_provider_id,
|
||||
commands::get_omo_provider_count,
|
||||
commands::disable_current_omo,
|
||||
// Workspace files (OpenClaw)
|
||||
commands::read_workspace_file,
|
||||
commands::write_workspace_file,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
//! OpenClaw 配置文件读写模块
|
||||
//!
|
||||
//! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作(JSON5 格式)。
|
||||
//! OpenClaw 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
|
||||
//!
|
||||
//! ## 配置文件格式
|
||||
//!
|
||||
//! ```json5
|
||||
//! {
|
||||
//! // 模型供应商配置(映射为 CC Switch 的"供应商")
|
||||
//! models: {
|
||||
//! mode: "merge",
|
||||
//! providers: {
|
||||
//! "custom-provider": {
|
||||
//! baseUrl: "https://api.example.com/v1",
|
||||
//! apiKey: "${API_KEY}",
|
||||
//! api: "openai-completions",
|
||||
//! models: [{ id: "model-id", name: "Model Name" }]
|
||||
//! }
|
||||
//! }
|
||||
//! },
|
||||
//! // 环境变量配置
|
||||
//! env: {
|
||||
//! ANTHROPIC_API_KEY: "sk-...",
|
||||
//! vars: { ... }
|
||||
//! },
|
||||
//! // Agent 默认模型配置
|
||||
//! agents: {
|
||||
//! defaults: {
|
||||
//! model: {
|
||||
//! primary: "provider/model",
|
||||
//! fallbacks: ["provider2/model2"]
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::config::write_json_file;
|
||||
use crate::error::AppError;
|
||||
use crate::settings::get_openclaw_override_dir;
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ============================================================================
|
||||
// Path Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 获取 OpenClaw 配置目录
|
||||
///
|
||||
/// 默认路径: `~/.openclaw/`
|
||||
/// 可通过 settings.openclaw_config_dir 覆盖
|
||||
pub fn get_openclaw_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_openclaw_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
// 所有平台统一使用 ~/.openclaw
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".openclaw"))
|
||||
.unwrap_or_else(|| PathBuf::from(".openclaw"))
|
||||
}
|
||||
|
||||
/// 获取 OpenClaw 配置文件路径
|
||||
///
|
||||
/// 返回 `~/.openclaw/openclaw.json`
|
||||
pub fn get_openclaw_config_path() -> PathBuf {
|
||||
get_openclaw_dir().join("openclaw.json")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// OpenClaw 供应商配置(对应 models.providers 中的条目)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenClawProviderConfig {
|
||||
/// API 基础 URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
|
||||
/// API Key(支持环境变量引用 ${VAR_NAME})
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
/// API 类型(如 "openai-completions", "anthropic" 等)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<String>,
|
||||
|
||||
/// 支持的模型列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<OpenClawModelEntry>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw 模型条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenClawModelEntry {
|
||||
/// 模型 ID
|
||||
pub id: String,
|
||||
|
||||
/// 模型显示名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// 模型别名(用于快捷引用)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// 模型成本(输入/输出价格)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<OpenClawModelCost>,
|
||||
|
||||
/// 上下文窗口大小
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_window: Option<u32>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw 模型成本配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawModelCost {
|
||||
/// 输入价格(每百万 token)
|
||||
pub input: f64,
|
||||
|
||||
/// 输出价格(每百万 token)
|
||||
pub output: f64,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw 默认模型配置(agents.defaults.model)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawDefaultModel {
|
||||
/// 主模型 ID(格式:provider/model)
|
||||
pub primary: String,
|
||||
|
||||
/// 回退模型列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub fallbacks: Vec<String>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw 模型目录条目(agents.defaults.models 中的值)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawModelCatalogEntry {
|
||||
/// 模型别名(用于 UI 显示)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw agents.defaults 配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawAgentsDefaults {
|
||||
/// 默认模型配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<OpenClawDefaultModel>,
|
||||
|
||||
/// 模型目录/允许列表(键为 provider/model 格式)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub models: Option<HashMap<String, OpenClawModelCatalogEntry>>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw agents 顶层配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OpenClawAgents {
|
||||
/// 默认配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub defaults: Option<OpenClawAgentsDefaults>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Read/Write Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 读取 OpenClaw 配置文件
|
||||
///
|
||||
/// 支持 JSON5 格式,返回完整的配置 JSON 对象
|
||||
pub fn read_openclaw_config() -> Result<Value, AppError> {
|
||||
let path = get_openclaw_config_path();
|
||||
|
||||
if !path.exists() {
|
||||
// Return empty config structure
|
||||
return Ok(json!({
|
||||
"models": {
|
||||
"mode": "merge",
|
||||
"providers": {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
|
||||
// 尝试 JSON5 解析(支持注释和尾随逗号)
|
||||
json5::from_str(&content)
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {}", e)))
|
||||
}
|
||||
|
||||
/// 写入 OpenClaw 配置文件(原子写入)
|
||||
///
|
||||
/// 使用标准 JSON 格式写入(JSON5 是 JSON 的超集)
|
||||
pub fn write_openclaw_config(config: &Value) -> Result<(), AppError> {
|
||||
let path = get_openclaw_config_path();
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
// 复用统一的原子写入逻辑
|
||||
write_json_file(&path, config)?;
|
||||
|
||||
log::debug!("OpenClaw config written to {path:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Functions (Untyped - for raw JSON operations)
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有供应商配置(原始 JSON)
|
||||
///
|
||||
/// 从 `models.providers` 读取
|
||||
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
Ok(config
|
||||
.get("models")
|
||||
.and_then(|m| m.get("providers"))
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 设置供应商配置(原始 JSON)
|
||||
///
|
||||
/// 写入到 `models.providers`
|
||||
pub fn set_provider(id: &str, provider_config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_openclaw_config()?;
|
||||
|
||||
// 确保 models 结构存在
|
||||
if full_config.get("models").is_none() {
|
||||
full_config["models"] = json!({
|
||||
"mode": "merge",
|
||||
"providers": {}
|
||||
});
|
||||
}
|
||||
|
||||
// 确保 providers 对象存在
|
||||
if full_config["models"].get("providers").is_none() {
|
||||
full_config["models"]["providers"] = json!({});
|
||||
}
|
||||
|
||||
// 设置供应商
|
||||
if let Some(providers) = full_config["models"]
|
||||
.get_mut("providers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
providers.insert(id.to_string(), provider_config);
|
||||
}
|
||||
|
||||
write_openclaw_config(&full_config)
|
||||
}
|
||||
|
||||
/// 删除供应商配置
|
||||
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
if let Some(providers) = config
|
||||
.get_mut("models")
|
||||
.and_then(|m| m.get_mut("providers"))
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
providers.remove(id);
|
||||
}
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Functions (Typed)
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有供应商配置(类型化)
|
||||
pub fn get_typed_providers() -> Result<IndexMap<String, OpenClawProviderConfig>, AppError> {
|
||||
let providers = get_providers()?;
|
||||
let mut result = IndexMap::new();
|
||||
|
||||
for (id, value) in providers {
|
||||
match serde_json::from_value::<OpenClawProviderConfig>(value.clone()) {
|
||||
Ok(config) => {
|
||||
result.insert(id, config);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse OpenClaw provider '{id}': {e}");
|
||||
// Skip invalid providers but continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 设置供应商配置(类型化)
|
||||
pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<(), AppError> {
|
||||
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
set_provider(id, value)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agents Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 读取默认模型配置(agents.defaults.model)
|
||||
pub fn get_default_model() -> Result<Option<OpenClawDefaultModel>, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(model_value) = config
|
||||
.get("agents")
|
||||
.and_then(|a| a.get("defaults"))
|
||||
.and_then(|d| d.get("model"))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let model = serde_json::from_value(model_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.model: {e}")))?;
|
||||
|
||||
Ok(Some(model))
|
||||
}
|
||||
|
||||
/// 设置默认模型配置(agents.defaults.model)
|
||||
pub fn set_default_model(model: &OpenClawDefaultModel) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
// Ensure agents.defaults path exists, preserving unknown fields
|
||||
ensure_agents_defaults_path(&mut config);
|
||||
|
||||
let model_value =
|
||||
serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["agents"]["defaults"]["model"] = model_value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
/// 读取模型目录/允许列表(agents.defaults.models)
|
||||
pub fn get_model_catalog() -> Result<Option<HashMap<String, OpenClawModelCatalogEntry>>, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(models_value) = config
|
||||
.get("agents")
|
||||
.and_then(|a| a.get("defaults"))
|
||||
.and_then(|d| d.get("models"))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let catalog = serde_json::from_value(models_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.models: {e}")))?;
|
||||
|
||||
Ok(Some(catalog))
|
||||
}
|
||||
|
||||
/// 设置模型目录/允许列表(agents.defaults.models)
|
||||
pub fn set_model_catalog(
|
||||
catalog: &HashMap<String, OpenClawModelCatalogEntry>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
// Ensure agents.defaults path exists, preserving unknown fields
|
||||
ensure_agents_defaults_path(&mut config);
|
||||
|
||||
let catalog_value =
|
||||
serde_json::to_value(catalog).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["agents"]["defaults"]["models"] = catalog_value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
/// Ensure the `agents.defaults` path exists in the config,
|
||||
/// preserving any existing unknown fields.
|
||||
fn ensure_agents_defaults_path(config: &mut Value) {
|
||||
if config.get("agents").is_none() {
|
||||
config["agents"] = json!({});
|
||||
}
|
||||
if config["agents"].get("defaults").is_none() {
|
||||
config["agents"]["defaults"] = json!({});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Full Agents Defaults Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Read the full agents.defaults config
|
||||
pub fn get_agents_defaults() -> Result<Option<OpenClawAgentsDefaults>, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(defaults_value) = config.get("agents").and_then(|a| a.get("defaults")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let defaults = serde_json::from_value(defaults_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?;
|
||||
|
||||
Ok(Some(defaults))
|
||||
}
|
||||
|
||||
/// Write the full agents.defaults config
|
||||
pub fn set_agents_defaults(defaults: &OpenClawAgentsDefaults) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
if config.get("agents").is_none() {
|
||||
config["agents"] = json!({});
|
||||
}
|
||||
|
||||
let value =
|
||||
serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["agents"]["defaults"] = value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Env Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// OpenClaw env configuration (env section of openclaw.json)
|
||||
///
|
||||
/// Stores environment variables like API keys and custom vars.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawEnvConfig {
|
||||
/// All environment variable key-value pairs
|
||||
#[serde(flatten)]
|
||||
pub vars: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Read the env config section
|
||||
pub fn get_env_config() -> Result<OpenClawEnvConfig, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(env_value) = config.get("env") else {
|
||||
return Ok(OpenClawEnvConfig {
|
||||
vars: HashMap::new(),
|
||||
});
|
||||
};
|
||||
|
||||
serde_json::from_value(env_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse env config: {e}")))
|
||||
}
|
||||
|
||||
/// Write the env config section
|
||||
pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["env"] = value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tools Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// OpenClaw tools configuration (tools section of openclaw.json)
|
||||
///
|
||||
/// Controls tool permissions with profile-based allow/deny lists.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawToolsConfig {
|
||||
/// Active permission profile (e.g. "default", "strict", "permissive")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
|
||||
/// Allowed tool patterns
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub allow: Vec<String>,
|
||||
|
||||
/// Denied tool patterns
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub deny: Vec<String>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Read the tools config section
|
||||
pub fn get_tools_config() -> Result<OpenClawToolsConfig, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(tools_value) = config.get("tools") else {
|
||||
return Ok(OpenClawToolsConfig {
|
||||
profile: None,
|
||||
allow: Vec::new(),
|
||||
deny: Vec::new(),
|
||||
extra: HashMap::new(),
|
||||
});
|
||||
};
|
||||
|
||||
serde_json::from_value(tools_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse tools config: {e}")))
|
||||
}
|
||||
|
||||
/// Write the tools config section
|
||||
pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["tools"] = value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path;
|
||||
use crate::config::get_claude_settings_path;
|
||||
use crate::error::AppError;
|
||||
use crate::gemini_config::get_gemini_dir;
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
@@ -14,6 +15,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
AppType::Gemini => get_gemini_dir(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
@@ -21,6 +23,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode => "AGENTS.md",
|
||||
AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
|
||||
@@ -8,7 +8,10 @@ use super::{
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
@@ -157,6 +160,7 @@ impl RequestForwarder {
|
||||
|
||||
// 整流器重试标记:确保整流最多触发一次
|
||||
let mut rectifier_retried = false;
|
||||
let mut budget_rectifier_retried = false;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
@@ -258,6 +262,7 @@ impl RequestForwarder {
|
||||
provider_type,
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||
);
|
||||
let mut signature_rectifier_non_retryable_client_error = false;
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
@@ -293,12 +298,185 @@ impl RequestForwarder {
|
||||
// 首次触发:整流请求体
|
||||
let rectified = rectify_anthropic_request(&mut body);
|
||||
|
||||
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
|
||||
if !rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
|
||||
);
|
||||
signature_rectifier_non_retryable_client_error = true;
|
||||
} else {
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type_str.to_string(),
|
||||
(provider.id.clone(), provider.name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
let should_switch =
|
||||
self.current_provider_id_at_start.as_str()
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests
|
||||
as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_)
|
||||
| ProxyError::ForwardFailed(_) => true,
|
||||
ProxyError::UpstreamError { status, .. } => {
|
||||
*status >= 500
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_budget(
|
||||
error_message.as_deref(),
|
||||
&self.rectifier_config,
|
||||
) {
|
||||
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||
if budget_rectifier_retried {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
|
||||
);
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
let budget_rectified = rectify_thinking_budget(&mut body);
|
||||
if !budget_rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
|
||||
);
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
@@ -321,15 +499,13 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
budget_rectified.before,
|
||||
budget_rectified.after
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
@@ -337,8 +513,7 @@ impl RequestForwarder {
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
@@ -350,7 +525,6 @@ impl RequestForwarder {
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
@@ -360,7 +534,6 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
@@ -370,14 +543,11 @@ impl RequestForwarder {
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
@@ -397,12 +567,10 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||
true
|
||||
@@ -412,7 +580,6 @@ impl RequestForwarder {
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
@@ -424,7 +591,6 @@ impl RequestForwarder {
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
@@ -451,6 +617,28 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
if signature_rectifier_non_retryable_client_error {
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// 失败:记录失败并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
@@ -577,6 +765,9 @@ impl RequestForwarder {
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod url_builder;
|
||||
|
||||
@@ -97,11 +97,21 @@ impl ModelMapping {
|
||||
|
||||
/// 检测请求是否启用了 thinking 模式
|
||||
pub fn has_thinking_enabled(body: &Value) -> bool {
|
||||
body.get("thinking")
|
||||
match body
|
||||
.get("thinking")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| o.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled")
|
||||
{
|
||||
Some("enabled") | Some("adaptive") => true,
|
||||
Some("disabled") | None => false,
|
||||
Some(other) => {
|
||||
log::warn!(
|
||||
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 对请求体应用模型映射
|
||||
@@ -300,6 +310,30 @@ mod tests {
|
||||
assert!(mapped.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "adaptive"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-model");
|
||||
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_unknown_type() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "some_future_type"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
|
||||
@@ -136,6 +136,10 @@ impl ProviderType {
|
||||
// OpenCode doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +188,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
// OpenCode doesn't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Thinking Budget 整流器
|
||||
//!
|
||||
//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。
|
||||
//! 当上游 API 返回 budget_tokens 相关错误时,系统会自动调整 budget 参数并重试。
|
||||
|
||||
use super::types::RectifierConfig;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 最大 thinking budget tokens
|
||||
const MAX_THINKING_BUDGET: u64 = 32000;
|
||||
|
||||
/// 最大 max_tokens 值
|
||||
const MAX_TOKENS_VALUE: u64 = 64000;
|
||||
|
||||
/// max_tokens 必须大于 budget_tokens
|
||||
const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;
|
||||
|
||||
/// Budget 整流结果
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BudgetRectifySnapshot {
|
||||
/// max_tokens
|
||||
pub max_tokens: Option<u64>,
|
||||
/// thinking.type
|
||||
pub thinking_type: Option<String>,
|
||||
/// thinking.budget_tokens
|
||||
pub thinking_budget_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// Budget 整流结果
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BudgetRectifyResult {
|
||||
/// 是否应用了整流
|
||||
pub applied: bool,
|
||||
/// 整流前快照
|
||||
pub before: BudgetRectifySnapshot,
|
||||
/// 整流后快照
|
||||
pub after: BudgetRectifySnapshot,
|
||||
}
|
||||
|
||||
/// 检测是否需要触发 thinking budget 整流器
|
||||
///
|
||||
/// 检测条件:error message 同时包含 `budget_tokens` + `thinking` 相关约束
|
||||
pub fn should_rectify_thinking_budget(
|
||||
error_message: Option<&str>,
|
||||
config: &RectifierConfig,
|
||||
) -> bool {
|
||||
// 检查总开关
|
||||
if !config.enabled {
|
||||
return false;
|
||||
}
|
||||
// 检查子开关
|
||||
if !config.request_thinking_budget {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(msg) = error_message else {
|
||||
return false;
|
||||
};
|
||||
let lower = msg.to_lowercase();
|
||||
|
||||
// 与 CCH 对齐:仅在包含 budget_tokens + thinking + 1024 约束时触发
|
||||
let has_budget_tokens_reference =
|
||||
lower.contains("budget_tokens") || lower.contains("budget tokens");
|
||||
let has_thinking_reference = lower.contains("thinking");
|
||||
let has_1024_constraint = lower.contains("greater than or equal to 1024")
|
||||
|| lower.contains(">= 1024")
|
||||
|| (lower.contains("1024") && lower.contains("input should be"));
|
||||
if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 对请求体执行 budget 整流
|
||||
///
|
||||
/// 整流动作:
|
||||
/// - `thinking.type = "enabled"`
|
||||
/// - `thinking.budget_tokens = 32000`
|
||||
/// - 如果 `max_tokens < 32001`,设为 `64000`
|
||||
pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {
|
||||
let before = snapshot_budget(body);
|
||||
|
||||
// 与 CCH 对齐:adaptive 请求不改写
|
||||
if before.thinking_type.as_deref() == Some("adaptive") {
|
||||
return BudgetRectifyResult {
|
||||
applied: false,
|
||||
before: before.clone(),
|
||||
after: before,
|
||||
};
|
||||
}
|
||||
|
||||
// 与 CCH 对齐:缺少/非法 thinking 时自动创建后再整流
|
||||
if !body.get("thinking").is_some_and(Value::is_object) {
|
||||
body["thinking"] = Value::Object(serde_json::Map::new());
|
||||
}
|
||||
|
||||
let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else {
|
||||
return BudgetRectifyResult {
|
||||
applied: false,
|
||||
before: before.clone(),
|
||||
after: before,
|
||||
};
|
||||
};
|
||||
|
||||
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
|
||||
thinking.insert(
|
||||
"budget_tokens".to_string(),
|
||||
Value::Number(MAX_THINKING_BUDGET.into()),
|
||||
);
|
||||
|
||||
if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) {
|
||||
body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into());
|
||||
}
|
||||
|
||||
let after = snapshot_budget(body);
|
||||
BudgetRectifyResult {
|
||||
applied: before != after,
|
||||
before,
|
||||
after,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_budget(body: &Value) -> BudgetRectifySnapshot {
|
||||
let max_tokens = body.get("max_tokens").and_then(|v| v.as_u64());
|
||||
let thinking = body.get("thinking").and_then(|t| t.as_object());
|
||||
let thinking_type = thinking
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string);
|
||||
let thinking_budget_tokens = thinking
|
||||
.and_then(|t| t.get("budget_tokens"))
|
||||
.and_then(|v| v.as_u64());
|
||||
BudgetRectifySnapshot {
|
||||
max_tokens,
|
||||
thinking_type,
|
||||
thinking_budget_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn enabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn budget_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn master_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== should_rectify_thinking_budget 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_thinking_error() {
|
||||
assert!(should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_max_tokens_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("budget_tokens must be less than max_tokens"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_1024_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("budget_tokens: value must be at least 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_with_thinking_and_1024_error() {
|
||||
assert!(should_rectify_thinking_budget(
|
||||
Some("thinking budget_tokens must be >= 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_trigger_for_unrelated_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("Request timeout"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_budget(None, &enabled_config()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_budget_config() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&budget_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_disabled() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&master_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== rectify_thinking_budget 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_basic() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(result.before.thinking_budget_tokens, Some(512));
|
||||
assert_eq!(
|
||||
result.after.thinking_budget_tokens,
|
||||
Some(MAX_THINKING_BUDGET)
|
||||
);
|
||||
assert_eq!(result.before.max_tokens, Some(1024));
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_skips_adaptive() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.before, result.after);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 512);
|
||||
assert_eq!(body["max_tokens"], 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_preserves_large_max_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.max_tokens, Some(100000));
|
||||
assert_eq!(result.after.max_tokens, Some(100000));
|
||||
assert_eq!(body["max_tokens"], 100000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_creates_thinking_object_when_missing() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type, None);
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(
|
||||
result.after.thinking_budget_tokens,
|
||||
Some(MAX_THINKING_BUDGET)
|
||||
);
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_no_max_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 }
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.max_tokens, None);
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_normalizes_non_enabled_type() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "disabled", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type.as_deref(), Some("disabled"));
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_no_change_when_already_valid() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 32000 },
|
||||
"max_tokens": 64001
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.before, result.after);
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 32000);
|
||||
assert_eq!(body["max_tokens"], 64001);
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,12 @@ pub fn should_rectify_thinking_signature(
|
||||
}
|
||||
|
||||
// 场景3: expected thinking or redacted_thinking, found tool_use
|
||||
// 与 CCH 对齐:要求明确包含 tool_use,避免过宽匹配。
|
||||
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
||||
if lower.contains("expected")
|
||||
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("found")
|
||||
&& lower.contains("tool_use")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -73,6 +75,28 @@ pub fn should_rectify_thinking_signature(
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景5: signature 字段不被接受(第三方渠道)
|
||||
// 错误示例: "xxx.signature: Extra inputs are not permitted"
|
||||
if lower.contains("signature") && lower.contains("extra inputs are not permitted") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景6: thinking/redacted_thinking 块被修改
|
||||
// 错误示例: "thinking or redacted_thinking blocks ... cannot be modified"
|
||||
if (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("cannot be modified")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景7: 非法请求(与 CCH 对齐,按 invalid request 统一兜底)
|
||||
if lower.contains("非法请求")
|
||||
|| lower.contains("illegal request")
|
||||
|| lower.contains("invalid request")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -159,11 +183,13 @@ pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
||||
/// 判断是否需要删除顶层 thinking 字段
|
||||
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
// 检查 thinking 是否启用
|
||||
let thinking_enabled = body
|
||||
let thinking_type = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled");
|
||||
.and_then(|t| t.as_str());
|
||||
|
||||
// 与 CCH 对齐:仅 type=enabled 视为开启
|
||||
let thinking_enabled = thinking_type == Some("enabled");
|
||||
|
||||
if !thinking_enabled {
|
||||
return false;
|
||||
@@ -202,6 +228,11 @@ fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||
}
|
||||
|
||||
/// 与 CCH 对齐:请求前不做 thinking type 主动改写。
|
||||
pub fn normalize_thinking_type(body: Value) -> Value {
|
||||
body
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -211,6 +242,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +250,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: false,
|
||||
request_thinking_budget: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +258,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +298,14 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_detect_thinking_expected_without_tool_use() {
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`."),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_must_start_with_thinking() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
@@ -418,4 +460,230 @@ mod tests {
|
||||
// 此时会触发删除顶层 thinking 的逻辑
|
||||
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
||||
}
|
||||
|
||||
// ==================== 新增错误场景检测测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_signature_extra_inputs() {
|
||||
// 场景5: signature 字段不被接受
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("xxx.signature: Extra inputs are not permitted"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_thinking_cannot_be_modified() {
|
||||
// 场景6: thinking blocks cannot be modified
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("thinking or redacted_thinking blocks in the response cannot be modified"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_request() {
|
||||
// 场景7: 非法请求(与 CCH 对齐,统一触发)
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("非法请求:thinking signature 不合法"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("illegal request: tool_use block mismatch"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("invalid request: malformed JSON"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_do_not_detect_thinking_type_tag_mismatch() {
|
||||
// 与 CCH 对齐:adaptive tag mismatch 不触发签名整流器
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Input tag 'adaptive' found using 'type' does not match expected tags"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== adaptive thinking type 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_keeps_adaptive_when_no_legacy_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert!(body["thinking"].get("budget_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_adaptive_preserves_existing_budget_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 5000 },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_does_not_change_enabled_type() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_top_level_thinking_adaptive() {
|
||||
// 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除,adaptive 不删除
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
||||
]
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "t", "signature": "sig_thinking" },
|
||||
{ "type": "text", "text": "hello", "signature": "sig_text" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert!(content[0].get("signature").is_none());
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
}
|
||||
|
||||
// ==================== normalize_thinking_type 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_adaptive_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "adaptive");
|
||||
assert!(result["thinking"].get("budget_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_enabled_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 2048 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "enabled");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_disabled_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "disabled" }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_preserves_budget() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 5000 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "adaptive");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_no_thinking() {
|
||||
let body = json!({
|
||||
"model": "claude-test"
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert!(result.get("thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_unknown_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "unexpected", "budget_tokens": 100 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "unexpected");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@ pub struct ProxyTakeoverStatus {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
/// API 格式类型(预留,当前不需要格式转换)
|
||||
@@ -195,17 +197,22 @@ pub struct AppProxyConfig {
|
||||
/// 整流器配置
|
||||
///
|
||||
/// 存储在 settings 表中
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RectifierConfig {
|
||||
/// 总开关:是否启用整流器
|
||||
#[serde(default)]
|
||||
/// 总开关:是否启用整流器(默认开启)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 请求整流:启用 thinking 签名整流器
|
||||
/// 请求整流:启用 thinking 签名整流器(默认开启)
|
||||
///
|
||||
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_signature: bool,
|
||||
/// 请求整流:启用 thinking budget 整流器(默认开启)
|
||||
///
|
||||
/// 处理错误:budget_tokens + thinking 相关约束
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_budget: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -216,6 +223,16 @@ fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
impl Default for RectifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
@@ -261,32 +278,49 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_default_disabled() {
|
||||
// 验证 RectifierConfig::default() 返回全禁用状态
|
||||
fn test_rectifier_config_default_enabled() {
|
||||
// 验证 RectifierConfig::default() 返回全开启状态
|
||||
let config = RectifierConfig::default();
|
||||
assert!(!config.enabled, "整流器总开关默认应为 false");
|
||||
assert!(config.enabled, "整流器总开关默认应为 true");
|
||||
assert!(
|
||||
!config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 false"
|
||||
config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 true"
|
||||
);
|
||||
assert!(
|
||||
config.request_thinking_budget,
|
||||
"thinking budget 整流器默认应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_default() {
|
||||
// 验证反序列化缺字段时使用默认值 false
|
||||
// 验证反序列化缺字段时使用默认值 true
|
||||
let json = "{}";
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_explicit_true() {
|
||||
// 验证显式设置 true 时正确反序列化
|
||||
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
|
||||
let json =
|
||||
r#"{"enabled": true, "requestThinkingSignature": true, "requestThinkingBudget": true}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_partial_fields() {
|
||||
// 验证只设置部分字段时,缺失字段使用默认值 true
|
||||
let json = r#"{"enabled": true, "requestThinkingSignature": false}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -126,6 +126,10 @@ impl ConfigService {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses additive mode, no live sync needed
|
||||
// OpenClaw providers are managed directly in the config file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -123,6 +123,11 @@ impl McpService {
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw MCP support is still in development (Issue #4834)
|
||||
// Skip for now
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping sync");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -148,6 +153,10 @@ impl McpService {
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw MCP support is still in development
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping remove");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
pub mod usage_stats;
|
||||
pub mod webdav;
|
||||
pub mod webdav_sync;
|
||||
|
||||
pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
|
||||
@@ -286,10 +286,22 @@ impl OmoService {
|
||||
|
||||
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||
|
||||
Ok(Self::build_local_file_data_from_obj(
|
||||
&obj,
|
||||
actual_path.to_string_lossy().to_string(),
|
||||
last_modified,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_local_file_data_from_obj(
|
||||
obj: &Map<String, Value>,
|
||||
file_path: String,
|
||||
last_modified: Option<String>,
|
||||
) -> OmoLocalFileData {
|
||||
let agents = obj.get("agents").cloned();
|
||||
let categories = obj.get("categories").cloned();
|
||||
|
||||
let other = Self::extract_other_fields(&obj);
|
||||
let other = Self::extract_other_fields(obj);
|
||||
let other_fields = if other.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -297,16 +309,17 @@ impl OmoService {
|
||||
};
|
||||
|
||||
let mut global = OmoGlobalConfig::default();
|
||||
Self::merge_global_from_obj(&obj, &mut global);
|
||||
Self::merge_global_from_obj(obj, &mut global);
|
||||
global.other_fields = other_fields.clone();
|
||||
|
||||
Ok(OmoLocalFileData {
|
||||
OmoLocalFileData {
|
||||
agents,
|
||||
categories,
|
||||
other_fields,
|
||||
global,
|
||||
file_path: actual_path.to_string_lossy().to_string(),
|
||||
file_path,
|
||||
last_modified,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_jsonc_comments(input: &str) -> String {
|
||||
@@ -400,7 +413,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let agents = Some(serde_json::json!({
|
||||
"Sisyphus": { "model": "claude-opus-4-5" }
|
||||
"sisyphus": { "model": "claude-opus-4-5" }
|
||||
}));
|
||||
let categories = None;
|
||||
let other_fields = None;
|
||||
@@ -411,7 +424,7 @@ mod tests {
|
||||
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
||||
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
|
||||
assert!(obj.contains_key("agents"));
|
||||
assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5");
|
||||
assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -422,7 +435,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let agents = Some(serde_json::json!({
|
||||
"Sisyphus": { "model": "claude-opus-4-5" }
|
||||
"sisyphus": { "model": "claude-opus-4-5" }
|
||||
}));
|
||||
let categories = None;
|
||||
let other_fields = None;
|
||||
@@ -434,4 +447,61 @@ mod tests {
|
||||
assert!(!obj.contains_key("disabled_agents"));
|
||||
assert!(obj.contains_key("agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() {
|
||||
let obj = serde_json::json!({
|
||||
"$schema": "https://example.com/schema.json",
|
||||
"disabled_agents": ["oracle"],
|
||||
"agents": {
|
||||
"sisyphus": { "model": "claude-opus-4-6" }
|
||||
},
|
||||
"categories": {
|
||||
"code": { "model": "gpt-5.3" }
|
||||
},
|
||||
"custom_top_level": {
|
||||
"enabled": true
|
||||
}
|
||||
});
|
||||
let obj_map = obj.as_object().unwrap().clone();
|
||||
|
||||
let data = OmoService::build_local_file_data_from_obj(
|
||||
&obj_map,
|
||||
"/tmp/oh-my-opencode.jsonc".to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
data.global.schema_url.as_deref(),
|
||||
Some("https://example.com/schema.json")
|
||||
);
|
||||
assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]);
|
||||
|
||||
assert_eq!(
|
||||
data.other_fields,
|
||||
Some(serde_json::json!({
|
||||
"custom_top_level": { "enabled": true }
|
||||
}))
|
||||
);
|
||||
assert_eq!(data.global.other_fields, data.other_fields);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_config_ignores_non_object_other_fields() {
|
||||
let global = OmoGlobalConfig {
|
||||
other_fields: Some(serde_json::json!(["global_non_object"])),
|
||||
..Default::default()
|
||||
};
|
||||
let agents = None;
|
||||
let categories = None;
|
||||
let other_fields = Some(serde_json::json!("profile_non_object"));
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
|
||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
assert!(!obj.contains_key("0"));
|
||||
assert!(!obj.contains_key("global_non_object"));
|
||||
assert!(!obj.contains_key("profile_non_object"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,48 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
}
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses additive mode - write provider to config
|
||||
use crate::openclaw_config;
|
||||
use crate::openclaw_config::OpenClawProviderConfig;
|
||||
|
||||
// Convert settings_config to OpenClawProviderConfig
|
||||
let openclaw_config_result =
|
||||
serde_json::from_value::<OpenClawProviderConfig>(provider.settings_config.clone());
|
||||
|
||||
match openclaw_config_result {
|
||||
Ok(config) => {
|
||||
openclaw_config::set_typed_provider(&provider.id, &config)?;
|
||||
log::info!("OpenClaw provider '{}' written to live config", provider.id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to parse OpenClaw provider config for '{}': {}",
|
||||
provider.id,
|
||||
e
|
||||
);
|
||||
// Try to write as raw JSON if it looks valid
|
||||
if provider.settings_config.get("baseUrl").is_some()
|
||||
|| provider.settings_config.get("api").is_some()
|
||||
|| provider.settings_config.get("models").is_some()
|
||||
{
|
||||
openclaw_config::set_provider(
|
||||
&provider.id,
|
||||
provider.settings_config.clone(),
|
||||
)?;
|
||||
log::info!(
|
||||
"OpenClaw provider '{}' written as raw JSON to live config",
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenClaw provider '{}' has invalid config structure, skipping write",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -340,6 +382,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
|
||||
|
||||
let config_path = get_openclaw_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"openclaw.config.missing",
|
||||
"OpenClaw 配置文件不存在",
|
||||
"OpenClaw configuration file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let config = read_openclaw_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +405,12 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
/// Returns `Ok(true)` if a provider was actually imported,
|
||||
/// `Ok(false)` if skipped (providers already exist for this app).
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
// Additive mode apps (OpenCode, OpenClaw) should use their dedicated
|
||||
// import_xxx_providers_from_live functions, not this generic default config import
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
{
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if !providers.is_empty() {
|
||||
@@ -415,23 +478,9 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - import from live is not the same pattern
|
||||
// For now, return an empty config structure
|
||||
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||
|
||||
let config_path = get_opencode_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"opencode.live.missing",
|
||||
"OpenCode 配置文件不存在",
|
||||
"OpenCode configuration file is missing",
|
||||
));
|
||||
}
|
||||
|
||||
// For OpenCode, we return the full config - but note that OpenCode
|
||||
// uses additive mode, so importing defaults works differently
|
||||
read_opencode_config()?
|
||||
// OpenCode and OpenClaw use additive mode and are handled by early return above
|
||||
AppType::OpenCode | AppType::OpenClaw => {
|
||||
unreachable!("additive mode apps are handled by early return")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -609,3 +658,87 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Import all providers from OpenClaw live config to database
|
||||
///
|
||||
/// This imports existing providers from ~/.openclaw/openclaw.json
|
||||
/// into the CC Switch database. Each provider found will be added to the
|
||||
/// database with is_current set to false.
|
||||
pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, AppError> {
|
||||
use crate::openclaw_config;
|
||||
|
||||
let providers = openclaw_config::get_typed_providers()?;
|
||||
if providers.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut imported = 0;
|
||||
let existing = state.db.get_all_providers("openclaw")?;
|
||||
|
||||
for (id, config) in providers {
|
||||
// Validate: skip entries with empty id or no models
|
||||
if id.trim().is_empty() {
|
||||
log::warn!("Skipping OpenClaw provider with empty id");
|
||||
continue;
|
||||
}
|
||||
if config.models.is_empty() {
|
||||
log::warn!("Skipping OpenClaw provider '{id}': no models defined");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already exists in database
|
||||
if existing.contains_key(&id) {
|
||||
log::debug!("OpenClaw provider '{id}' already exists in database, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to Value for settings_config
|
||||
let settings_config = match serde_json::to_value(&config) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine display name: use first model name if available, otherwise use id
|
||||
let display_name = config
|
||||
.models
|
||||
.first()
|
||||
.and_then(|m| m.name.clone())
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
imported += 1;
|
||||
log::info!("Imported OpenClaw provider '{id}' from live config");
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Remove an OpenClaw provider from live config
|
||||
///
|
||||
/// This removes a specific provider from ~/.openclaw/openclaw.json
|
||||
/// without affecting other providers in the file.
|
||||
pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppError> {
|
||||
use crate::openclaw_config;
|
||||
|
||||
// Check if OpenClaw config directory exists
|
||||
if !openclaw_config::get_openclaw_dir().exists() {
|
||||
log::debug!("OpenClaw config directory doesn't exist, skipping removal of '{provider_id}'");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
openclaw_config::remove_provider(provider_id)?;
|
||||
log::info!("OpenClaw provider '{provider_id}' removed from live config");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ use crate::store::AppState;
|
||||
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{
|
||||
import_default_config, import_opencode_providers_from_live, read_live_settings,
|
||||
sync_current_to_live,
|
||||
import_default_config, import_openclaw_providers_from_live,
|
||||
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
@@ -30,7 +30,9 @@ pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||
use live::{
|
||||
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
|
||||
};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
/// Provider business logic service
|
||||
@@ -142,10 +144,10 @@ impl ProviderService {
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||
///
|
||||
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||
/// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。
|
||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||
// OpenCode uses additive mode - no "current" provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps have no "current" provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||
@@ -162,10 +164,12 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// OpenCode uses additive mode - always write to live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
||||
if app_type.is_additive_mode() {
|
||||
// OMO providers use exclusive mode and write to dedicated config file.
|
||||
if provider.category.as_deref() == Some("omo") {
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
// Do not auto-enable newly added OMO providers.
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
@@ -201,9 +205,11 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// OpenCode uses additive mode - always update in live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
if provider.category.as_deref() == Some("omo") {
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
let is_omo_current = state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
|
||||
@@ -253,43 +259,48 @@ impl ProviderService {
|
||||
/// Delete a provider
|
||||
///
|
||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
/// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// OpenCode uses additive mode - no current provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let is_omo = state
|
||||
.db
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
|
||||
if is_omo {
|
||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||
let omo_count = state
|
||||
// Additive mode apps - no current provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let is_omo = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
|
||||
if omo_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
if is_omo {
|
||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||
let omo_count = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
if omo_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
// Also remove from live config
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
match app_type {
|
||||
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
|
||||
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
|
||||
_ => {} // Should not reach here
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -306,7 +317,7 @@ impl ProviderService {
|
||||
state.db.delete_provider(app_type.as_str(), id)
|
||||
}
|
||||
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)
|
||||
///
|
||||
/// Does NOT delete from database - provider remains in the list.
|
||||
/// This is used when user wants to "remove" a provider from active config
|
||||
@@ -338,7 +349,9 @@ impl ProviderService {
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
}
|
||||
}
|
||||
// Future: add other additive mode apps here
|
||||
AppType::OpenClaw => {
|
||||
remove_openclaw_provider_from_live(id)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Message(format!(
|
||||
"App {} does not support remove from live config",
|
||||
@@ -454,22 +467,25 @@ impl ProviderService {
|
||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
|
||||
match (current_id, matches!(app_type, AppType::OpenCode)) {
|
||||
(Some(current_id), false) if current_id != id => {
|
||||
// Only backfill when switching to a different provider.
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow.
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
if let Some(current_id) = current_id {
|
||||
if current_id != id {
|
||||
// Additive mode apps - all providers coexist in the same file,
|
||||
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||
if !app_type.is_additive_mode() {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps skip setting is_current (no such concept)
|
||||
if !app_type.is_additive_mode() {
|
||||
// Update local settings (device-level, takes priority)
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
|
||||
@@ -515,6 +531,7 @@ impl ProviderService {
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +545,7 @@ impl ProviderService {
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +702,27 @@ impl ProviderService {
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenClaw (JSON format)
|
||||
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
|
||||
// For common config, we exclude provider-specific fields like apiKey
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Remove provider-specific fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
obj.remove("apiKey");
|
||||
obj.remove("baseUrl");
|
||||
// Keep api and models as they might be common
|
||||
}
|
||||
|
||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
///
|
||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||
@@ -861,6 +900,17 @@ impl ProviderService {
|
||||
));
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses config structure: { baseUrl, apiKey, api, models }
|
||||
// Basic validation - must be an object
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err(AppError::localized(
|
||||
"provider.openclaw.settings.not_object",
|
||||
"OpenClaw 配置必须是 JSON 对象",
|
||||
"OpenClaw configuration must be a JSON object",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clean UsageScript configuration (common for all app types)
|
||||
@@ -1032,6 +1082,30 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses apiKey and baseUrl directly on the object
|
||||
let api_key = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.openclaw.api_key.missing",
|
||||
"缺少 API Key",
|
||||
"API key is missing",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let base_url = provider
|
||||
.settings_config
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,11 +210,16 @@ impl ProxyService {
|
||||
.await
|
||||
.map(|c| c.enabled)
|
||||
.unwrap_or(false);
|
||||
// OpenCode and OpenClaw don't support proxy features, always return false
|
||||
let opencode_enabled = false;
|
||||
let openclaw_enabled = false;
|
||||
|
||||
Ok(ProxyTakeoverStatus {
|
||||
claude: claude_enabled,
|
||||
codex: codex_enabled,
|
||||
gemini: gemini_enabled,
|
||||
opencode: opencode_enabled,
|
||||
openclaw: openclaw_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -372,6 +377,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
self.sync_live_config_to_provider(app_type, &live_config)
|
||||
@@ -588,6 +597,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -770,6 +782,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
@@ -982,6 +998,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1068,6 +1088,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1103,6 +1126,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1186,6 +1212,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
Err("OpenCode 不支持代理功能".to_string())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
Err("OpenClaw 不支持代理功能".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,6 +1237,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1250,6 +1284,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,11 @@ impl SkillService {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
if let Some(custom) = crate::settings::get_openclaw_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认路径:回退到用户主目录下的标准位置
|
||||
@@ -244,6 +249,7 @@ impl SkillService {
|
||||
AppType::Codex => home.join(".codex").join("skills"),
|
||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||
AppType::OpenClaw => home.join(".openclaw").join("skills"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,6 +568,7 @@ impl SkillService {
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
};
|
||||
|
||||
unmanaged
|
||||
@@ -611,6 +618,7 @@ impl SkillService {
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
};
|
||||
found_in.push(app_str.to_string());
|
||||
}
|
||||
|
||||
@@ -240,6 +240,14 @@ impl StreamCheckService {
|
||||
"OpenCode does not support health check yet",
|
||||
));
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support stream check yet
|
||||
return Err(AppError::localized(
|
||||
"openclaw_no_stream_check",
|
||||
"OpenClaw 暂不支持健康检查",
|
||||
"OpenClaw does not support health check yet",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
@@ -567,6 +575,11 @@ impl StreamCheckService {
|
||||
// Try to extract first model from the models object
|
||||
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses models array in settings_config
|
||||
// Try to extract first model from the models array
|
||||
Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,6 +593,21 @@ impl StreamCheckService {
|
||||
models.keys().next().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_openclaw_model(provider: &Provider) -> Option<String> {
|
||||
// OpenClaw uses models array: [{ "id": "model-id", "name": "Model Name" }]
|
||||
let models = provider
|
||||
.settings_config
|
||||
.get("models")
|
||||
.and_then(|m| m.as_array())?;
|
||||
|
||||
// Return the first model ID from the models array
|
||||
models
|
||||
.first()
|
||||
.and_then(|m| m.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! WebDAV HTTP transport layer.
|
||||
//!
|
||||
//! Low-level HTTP primitives for WebDAV operations (PUT, GET, HEAD, MKCOL, PROPFIND).
|
||||
//! The sync protocol logic lives in [`super::webdav_sync`].
|
||||
|
||||
use reqwest::{Method, RequestBuilder, StatusCode, Url};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::http_client;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
/// Timeout for large file transfers (PUT/GET of db.sql, skills.zip).
|
||||
const TRANSFER_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Auth pair: `(username, Some(password))`.
|
||||
pub type WebDavAuth = Option<(String, Option<String>)>;
|
||||
|
||||
// ─── WebDAV extension methods ────────────────────────────────
|
||||
|
||||
fn method_propfind() -> Method {
|
||||
Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method")
|
||||
}
|
||||
|
||||
fn method_mkcol() -> Method {
|
||||
Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method")
|
||||
}
|
||||
|
||||
// ─── URL utilities ───────────────────────────────────────────
|
||||
|
||||
/// Parse and validate a WebDAV base URL (must be http or https).
|
||||
pub fn parse_base_url(raw: &str) -> Result<Url, AppError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"webdav.base_url.required",
|
||||
"WebDAV 地址不能为空",
|
||||
"WebDAV URL is required.",
|
||||
));
|
||||
}
|
||||
let url = Url::parse(trimmed).map_err(|e| {
|
||||
AppError::localized(
|
||||
"webdav.base_url.invalid",
|
||||
format!("WebDAV 地址无效: {e}"),
|
||||
format!("Invalid WebDAV URL: {e}"),
|
||||
)
|
||||
})?;
|
||||
match url.scheme() {
|
||||
"http" | "https" => Ok(url),
|
||||
_ => Err(AppError::localized(
|
||||
"webdav.base_url.scheme_invalid",
|
||||
"WebDAV 仅支持 http/https 地址",
|
||||
"WebDAV URL must use http or https.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a full URL from a base URL string and path segments.
|
||||
///
|
||||
/// Each segment is individually percent-encoded by the `url` crate.
|
||||
pub fn build_remote_url(base_url: &str, segments: &[String]) -> Result<String, AppError> {
|
||||
let mut url = parse_base_url(base_url)?;
|
||||
{
|
||||
let mut path = url.path_segments_mut().map_err(|_| {
|
||||
AppError::localized(
|
||||
"webdav.base_url.unusable",
|
||||
"WebDAV 地址格式不支持追加路径",
|
||||
"WebDAV URL format does not support appending path segments.",
|
||||
)
|
||||
})?;
|
||||
path.pop_if_empty();
|
||||
for seg in segments {
|
||||
path.push(seg);
|
||||
}
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Split a slash-delimited path into non-empty segments.
|
||||
pub fn path_segments(raw: &str) -> impl Iterator<Item = &str> {
|
||||
raw.trim_matches('/').split('/').filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────
|
||||
|
||||
/// Build auth from username/password. Returns `None` if username is blank.
|
||||
pub fn auth_from_credentials(username: &str, password: &str) -> WebDavAuth {
|
||||
let user = username.trim();
|
||||
if user.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((user.to_string(), Some(password.to_string())))
|
||||
}
|
||||
|
||||
/// Apply Basic-Auth to a request builder if auth is present.
|
||||
fn apply_auth(builder: RequestBuilder, auth: &WebDavAuth) -> RequestBuilder {
|
||||
match auth {
|
||||
Some((user, pass)) => builder.basic_auth(user, pass.as_deref()),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
|
||||
fn webdav_transport_error(
|
||||
key: &'static str,
|
||||
op_zh: &str,
|
||||
op_en: &str,
|
||||
target_url: &str,
|
||||
err: &reqwest::Error,
|
||||
) -> AppError {
|
||||
let (zh_reason, en_reason) = if err.is_timeout() {
|
||||
("请求超时", "request timed out")
|
||||
} else if err.is_connect() {
|
||||
("连接失败", "connection failed")
|
||||
} else if err.is_request() {
|
||||
("请求构造失败", "request build failed")
|
||||
} else {
|
||||
("网络请求失败", "network request failed")
|
||||
};
|
||||
|
||||
let safe_url = redact_url(target_url);
|
||||
AppError::localized(
|
||||
key,
|
||||
format!("WebDAV {op_zh}失败({zh_reason}): {safe_url}"),
|
||||
format!("WebDAV {op_en} failed ({en_reason}): {safe_url}"),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── HTTP operations ─────────────────────────────────────────
|
||||
|
||||
/// Test WebDAV connectivity via PROPFIND Depth=0 on the base URL.
|
||||
pub async fn test_connection(base_url: &str, auth: &WebDavAuth) -> Result<(), AppError> {
|
||||
let url = parse_base_url(base_url)?;
|
||||
let client = http_client::get();
|
||||
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.request(method_propfind(), url)
|
||||
.header("Depth", "0")
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.connection_failed",
|
||||
"连接",
|
||||
"connection",
|
||||
base_url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status().is_success() || resp.status() == StatusCode::MULTI_STATUS {
|
||||
return Ok(());
|
||||
}
|
||||
Err(webdav_status_error("PROPFIND", resp.status(), base_url))
|
||||
}
|
||||
|
||||
/// Ensure a chain of remote directories exists.
|
||||
///
|
||||
/// Uses optimistic MKCOL: try creating first, fall back to PROPFIND verification
|
||||
/// on ambiguous responses. This halves the round-trips vs PROPFIND-first approach.
|
||||
pub async fn ensure_remote_directories(
|
||||
base_url: &str,
|
||||
segments: &[String],
|
||||
auth: &WebDavAuth,
|
||||
) -> Result<(), AppError> {
|
||||
if segments.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let client = http_client::get();
|
||||
|
||||
for depth in 1..=segments.len() {
|
||||
let prefix = &segments[..depth];
|
||||
let url = build_remote_url(base_url, prefix)?;
|
||||
let dir_url = if url.ends_with('/') {
|
||||
url
|
||||
} else {
|
||||
format!("{url}/")
|
||||
};
|
||||
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.request(method_mkcol(), &dir_url)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.mkcol_failed",
|
||||
"MKCOL 请求",
|
||||
"MKCOL request",
|
||||
&dir_url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
match status {
|
||||
s if s == StatusCode::CREATED || s.is_success() => {
|
||||
log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url));
|
||||
}
|
||||
// 405 commonly means "already exists" on many WebDAV servers
|
||||
StatusCode::METHOD_NOT_ALLOWED => {}
|
||||
// Ambiguous — verify directory actually exists via PROPFIND
|
||||
s if s == StatusCode::CONFLICT || s.is_redirection() => {
|
||||
if !propfind_exists(&client, &dir_url, auth).await? {
|
||||
return Err(webdav_status_error("MKCOL", status, &dir_url));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(webdav_status_error("MKCOL", status, &dir_url));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PUT bytes to a remote WebDAV URL.
|
||||
pub async fn put_bytes(
|
||||
url: &str,
|
||||
auth: &WebDavAuth,
|
||||
bytes: Vec<u8>,
|
||||
content_type: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let client = http_client::get();
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.put(url)
|
||||
.header("Content-Type", content_type)
|
||||
.body(bytes)
|
||||
.timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.put_failed",
|
||||
"PUT 请求",
|
||||
"PUT request",
|
||||
url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(webdav_status_error("PUT", resp.status(), url))
|
||||
}
|
||||
|
||||
/// GET bytes from a remote WebDAV URL. Returns `None` on 404.
|
||||
///
|
||||
/// On success returns `(body_bytes, optional_etag)`.
|
||||
pub async fn get_bytes(
|
||||
url: &str,
|
||||
auth: &WebDavAuth,
|
||||
) -> Result<Option<(Vec<u8>, Option<String>)>, AppError> {
|
||||
let client = http_client::get();
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.get_failed",
|
||||
"GET 请求",
|
||||
"GET request",
|
||||
url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
return Err(webdav_status_error("GET", resp.status(), url));
|
||||
}
|
||||
let etag = resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"webdav.response_read_failed",
|
||||
format!("读取 WebDAV 响应失败: {e}"),
|
||||
format!("Failed to read WebDAV response: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Some((bytes.to_vec(), etag)))
|
||||
}
|
||||
|
||||
/// HEAD request to retrieve the ETag. Returns `None` on 404.
|
||||
pub async fn head_etag(url: &str, auth: &WebDavAuth) -> Result<Option<String>, AppError> {
|
||||
let client = http_client::get();
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.head(url)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.head_failed",
|
||||
"HEAD 请求",
|
||||
"HEAD request",
|
||||
url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
return Err(webdav_status_error("HEAD", resp.status(), url));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────
|
||||
|
||||
/// PROPFIND Depth=0 to check if a remote resource exists.
|
||||
async fn propfind_exists(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
auth: &WebDavAuth,
|
||||
) -> Result<bool, AppError> {
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.request(method_propfind(), url)
|
||||
.header("Depth", "0")
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(r) => Ok(r.status().is_success() || r.status() == StatusCode::MULTI_STATUS),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[WebDAV] PROPFIND check failed for {}: {e}",
|
||||
redact_url(url)
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Service detection & error helpers ───────────────────────
|
||||
|
||||
/// Check if a URL points to Jianguoyun (坚果云).
|
||||
pub fn is_jianguoyun(url: &str) -> bool {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
|
||||
.map(|host| host.contains("jianguoyun.com") || host.contains("nutstore"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build an `AppError` with service-specific hints for WebDAV failures.
|
||||
pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError {
|
||||
let safe_url = redact_url(url);
|
||||
let mut zh = format!("WebDAV {op} 失败: {status} ({safe_url})");
|
||||
let mut en = format!("WebDAV {op} failed: {status} ({safe_url})");
|
||||
let jgy = is_jianguoyun(url);
|
||||
|
||||
if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
||||
if jgy {
|
||||
zh.push_str(
|
||||
"。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。",
|
||||
);
|
||||
en.push_str(
|
||||
". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.",
|
||||
);
|
||||
} else {
|
||||
zh.push_str("。请检查 WebDAV 用户名、密码及目录读写权限。");
|
||||
en.push_str(". Please check WebDAV username/password and directory permissions.");
|
||||
}
|
||||
} else if jgy && (status == StatusCode::NOT_FOUND || status.is_redirection()) {
|
||||
zh.push_str("。坚果云常见原因:地址不在 /dav/ 可写目录下。");
|
||||
en.push_str(". Common Jianguoyun cause: URL is outside a writable /dav/ directory.");
|
||||
} else if op == "MKCOL" && status == StatusCode::CONFLICT {
|
||||
if jgy {
|
||||
zh.push_str(
|
||||
"。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。",
|
||||
);
|
||||
en.push_str(
|
||||
". Jianguoyun does not allow creating top-level folders automatically; create it manually first.",
|
||||
);
|
||||
} else {
|
||||
zh.push_str("。请确认上级目录存在。");
|
||||
en.push_str(". Please ensure the parent directory exists.");
|
||||
}
|
||||
}
|
||||
|
||||
AppError::localized("webdav.http.status", zh, en)
|
||||
}
|
||||
|
||||
fn redact_url(raw: &str) -> String {
|
||||
match Url::parse(raw) {
|
||||
Ok(mut parsed) => {
|
||||
let _ = parsed.set_username("");
|
||||
let _ = parsed.set_password(None);
|
||||
|
||||
let mut out = format!("{}://", parsed.scheme());
|
||||
if let Some(host) = parsed.host_str() {
|
||||
out.push_str(host);
|
||||
}
|
||||
if let Some(port) = parsed.port() {
|
||||
out.push(':');
|
||||
out.push_str(&port.to_string());
|
||||
}
|
||||
out.push_str(parsed.path());
|
||||
|
||||
let mut keys: Vec<String> = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
if !keys.is_empty() {
|
||||
out.push_str("?[keys:");
|
||||
out.push_str(&keys.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
out
|
||||
}
|
||||
Err(_) => raw.split('?').next().unwrap_or(raw).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_remote_url_encodes_path_segments() {
|
||||
let url = build_remote_url(
|
||||
"https://dav.example.com/remote.php/dav/files/demo/",
|
||||
&[
|
||||
"cc switch-sync".to_string(),
|
||||
"v2".to_string(),
|
||||
"default profile".to_string(),
|
||||
"manifest.json".to_string(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/default%20profile/manifest.json"
|
||||
);
|
||||
assert!(!url.contains("//cc"), "should not have double-slash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_jianguoyun_detects_correctly() {
|
||||
assert!(is_jianguoyun("https://dav.jianguoyun.com/dav"));
|
||||
assert!(is_jianguoyun("https://dav.jianguoyun.com/dav/folder"));
|
||||
assert!(!is_jianguoyun("https://nextcloud.example.com/dav"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_segments_splits_correctly() {
|
||||
let segs: Vec<_> = path_segments("/a/b/c/").collect();
|
||||
assert_eq!(segs, vec!["a", "b", "c"]);
|
||||
|
||||
let segs: Vec<_> = path_segments("single").collect();
|
||||
assert_eq!(segs, vec!["single"]);
|
||||
|
||||
let segs: Vec<_> = path_segments("").collect();
|
||||
assert!(segs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_from_credentials_trims_and_rejects_blank() {
|
||||
assert!(auth_from_credentials(" ", "pass").is_none());
|
||||
let auth = auth_from_credentials(" user ", "pass");
|
||||
assert_eq!(auth, Some(("user".to_string(), Some("pass".to_string()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_url_hides_credentials_and_query_values() {
|
||||
let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1");
|
||||
assert_eq!(
|
||||
redacted,
|
||||
"https://example.com:8443/dav?[keys:foo,token]"
|
||||
);
|
||||
assert!(!redacted.contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
//! WebDAV v2 sync protocol layer.
|
||||
//!
|
||||
//! Implements manifest-based synchronization on top of the HTTP transport
|
||||
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::webdav::{
|
||||
auth_from_credentials, build_remote_url, ensure_remote_directories, get_bytes, head_etag,
|
||||
path_segments, put_bytes, test_connection, WebDavAuth,
|
||||
};
|
||||
use crate::settings::{update_webdav_sync_status, WebDavSyncSettings, WebDavSyncStatus};
|
||||
|
||||
mod archive;
|
||||
use archive::{
|
||||
backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot,
|
||||
};
|
||||
|
||||
// ─── Protocol constants ──────────────────────────────────────
|
||||
|
||||
const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
|
||||
const PROTOCOL_VERSION: u32 = 2;
|
||||
const REMOTE_DB_SQL: &str = "db.sql";
|
||||
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
|
||||
const REMOTE_MANIFEST: &str = "manifest.json";
|
||||
const MAX_DEVICE_NAME_LEN: usize = 64;
|
||||
|
||||
fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {
|
||||
AppError::localized(key, zh, en)
|
||||
}
|
||||
|
||||
fn io_context_localized(
|
||||
_key: &'static str,
|
||||
zh: impl Into<String>,
|
||||
en: impl Into<String>,
|
||||
source: std::io::Error,
|
||||
) -> AppError {
|
||||
let zh_msg = zh.into();
|
||||
let en_msg = en.into();
|
||||
AppError::IoContext {
|
||||
context: format!("{zh_msg} ({en_msg})"),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncManifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
device_name: String,
|
||||
created_at: String,
|
||||
artifacts: BTreeMap<String, ArtifactMeta>,
|
||||
snapshot_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ArtifactMeta {
|
||||
sha256: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
struct LocalSnapshot {
|
||||
db_sql: Vec<u8>,
|
||||
skills_zip: Vec<u8>,
|
||||
manifest_bytes: Vec<u8>,
|
||||
manifest_hash: String,
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────
|
||||
|
||||
/// Check WebDAV connectivity and ensure remote directory structure.
|
||||
pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
test_connection(&settings.base_url, &auth).await?;
|
||||
let dir_segs = remote_dir_segments(settings);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload local snapshot (db + skills) to remote.
|
||||
pub async fn upload(
|
||||
db: &crate::database::Database,
|
||||
settings: &mut WebDavSyncSettings,
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
let dir_segs = remote_dir_segments(settings);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
|
||||
let snapshot = build_local_snapshot(db, settings)?;
|
||||
|
||||
// Upload order: artifacts first, manifest last (best-effort consistency)
|
||||
let db_url = remote_file_url(settings, REMOTE_DB_SQL)?;
|
||||
put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
|
||||
|
||||
let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?;
|
||||
put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
|
||||
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
put_bytes(
|
||||
&manifest_url,
|
||||
&auth,
|
||||
snapshot.manifest_bytes,
|
||||
"application/json",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fetch etag (best-effort, don't fail the upload)
|
||||
let etag = match head_etag(&manifest_url, &auth).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::debug!("[WebDAV] Failed to fetch ETag after upload: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let _persisted = persist_sync_success_best_effort(
|
||||
settings,
|
||||
snapshot.manifest_hash,
|
||||
etag,
|
||||
persist_sync_success,
|
||||
);
|
||||
Ok(serde_json::json!({ "status": "uploaded" }))
|
||||
}
|
||||
|
||||
/// Download remote snapshot and apply to local database + skills.
|
||||
pub async fn download(
|
||||
db: &crate::database::Database,
|
||||
settings: &mut WebDavSyncSettings,
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth).await?.ok_or_else(|| {
|
||||
localized(
|
||||
"webdav.sync.remote_empty",
|
||||
"远端没有可下载的同步数据",
|
||||
"No downloadable sync data found on the remote.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest: SyncManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
validate_manifest_compat(&manifest)?;
|
||||
|
||||
// Download and verify artifacts
|
||||
let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?;
|
||||
let skills_zip =
|
||||
download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?;
|
||||
|
||||
// Apply snapshot
|
||||
apply_snapshot(db, &db_sql, &skills_zip)?;
|
||||
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
let _persisted =
|
||||
persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success);
|
||||
Ok(serde_json::json!({ "status": "downloaded" }))
|
||||
}
|
||||
|
||||
/// Fetch remote manifest info without downloading artifacts.
|
||||
pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<Value>, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
|
||||
let Some((bytes, _)) = get_bytes(&manifest_url, &auth).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let compatible = validate_manifest_compat(&manifest).is_ok();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"deviceName": manifest.device_name,
|
||||
"createdAt": manifest.created_at,
|
||||
"snapshotId": manifest.snapshot_id,
|
||||
"version": manifest.version,
|
||||
"compatible": compatible,
|
||||
"artifacts": manifest.artifacts.keys().collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
// ─── Sync status persistence (I3: deduplicated) ─────────────
|
||||
|
||||
fn persist_sync_success(
|
||||
settings: &mut WebDavSyncSettings,
|
||||
manifest_hash: String,
|
||||
etag: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let status = WebDavSyncStatus {
|
||||
last_sync_at: Some(Utc::now().timestamp()),
|
||||
last_error: None,
|
||||
last_local_manifest_hash: Some(manifest_hash.clone()),
|
||||
last_remote_manifest_hash: Some(manifest_hash),
|
||||
last_remote_etag: etag,
|
||||
};
|
||||
settings.status = status.clone();
|
||||
update_webdav_sync_status(status)
|
||||
}
|
||||
|
||||
fn persist_sync_success_best_effort<F>(
|
||||
settings: &mut WebDavSyncSettings,
|
||||
manifest_hash: String,
|
||||
etag: Option<String>,
|
||||
persist_fn: F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnOnce(&mut WebDavSyncSettings, String, Option<String>) -> Result<(), AppError>,
|
||||
{
|
||||
match persist_fn(settings, manifest_hash, etag) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("[WebDAV] Persist sync status failed, keep operation success: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Snapshot building ───────────────────────────────────────
|
||||
|
||||
fn build_local_snapshot(
|
||||
db: &crate::database::Database,
|
||||
_settings: &WebDavSyncSettings,
|
||||
) -> Result<LocalSnapshot, AppError> {
|
||||
// Export database to SQL string
|
||||
let sql_string = db.export_sql_string()?;
|
||||
let db_sql = sql_string.into_bytes();
|
||||
|
||||
// Pack skills into deterministic ZIP
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"webdav.sync.snapshot_tmpdir_failed",
|
||||
"创建 WebDAV 快照临时目录失败",
|
||||
"Failed to create temporary directory for WebDAV snapshot",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
|
||||
zip_skills_ssot(&skills_zip_path)?;
|
||||
let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?;
|
||||
|
||||
// Build artifact map and compute hashes
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert(
|
||||
REMOTE_DB_SQL.to_string(),
|
||||
ArtifactMeta {
|
||||
sha256: sha256_hex(&db_sql),
|
||||
size: db_sql.len() as u64,
|
||||
},
|
||||
);
|
||||
artifacts.insert(
|
||||
REMOTE_SKILLS_ZIP.to_string(),
|
||||
ArtifactMeta {
|
||||
sha256: sha256_hex(&skills_zip),
|
||||
size: skills_zip.len() as u64,
|
||||
},
|
||||
);
|
||||
|
||||
let snapshot_id = compute_snapshot_id(&artifacts);
|
||||
let manifest = SyncManifest {
|
||||
format: PROTOCOL_FORMAT.to_string(),
|
||||
version: PROTOCOL_VERSION,
|
||||
device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
artifacts,
|
||||
snapshot_id,
|
||||
};
|
||||
let manifest_bytes =
|
||||
serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
|
||||
Ok(LocalSnapshot {
|
||||
db_sql,
|
||||
skills_zip,
|
||||
manifest_bytes,
|
||||
manifest_hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute a deterministic snapshot identity from artifact hashes.
|
||||
///
|
||||
/// BTreeMap iteration order is sorted by key, ensuring stability.
|
||||
fn compute_snapshot_id(artifacts: &BTreeMap<String, ArtifactMeta>) -> String {
|
||||
let parts: Vec<String> = artifacts
|
||||
.iter()
|
||||
.map(|(name, meta)| format!("{}:{}", name, meta.sha256))
|
||||
.collect();
|
||||
sha256_hex(parts.join("|").as_bytes())
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn detect_system_device_name() -> Option<String> {
|
||||
let env_name = [
|
||||
"CC_SWITCH_DEVICE_NAME",
|
||||
"COMPUTERNAME",
|
||||
"HOSTNAME",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var(key).ok())
|
||||
.find_map(|value| normalize_device_name(&value));
|
||||
|
||||
if env_name.is_some() {
|
||||
return env_name;
|
||||
}
|
||||
|
||||
let output = Command::new("hostname").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let hostname = String::from_utf8(output.stdout).ok()?;
|
||||
normalize_device_name(&hostname)
|
||||
}
|
||||
|
||||
fn normalize_device_name(raw: &str) -> Option<String> {
|
||||
let compact = raw.chars().fold(String::with_capacity(raw.len()), |mut acc, ch| {
|
||||
if ch.is_whitespace() {
|
||||
acc.push(' ');
|
||||
} else if !ch.is_control() {
|
||||
acc.push(ch);
|
||||
}
|
||||
acc
|
||||
});
|
||||
let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = normalized.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let limited = trimmed.chars().take(MAX_DEVICE_NAME_LEN).collect::<String>();
|
||||
if limited.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(limited)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> {
|
||||
if manifest.format != PROTOCOL_FORMAT {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_format_incompatible",
|
||||
format!("远端 manifest 格式不兼容: {}", manifest.format),
|
||||
format!(
|
||||
"Remote manifest format is incompatible: {}",
|
||||
manifest.format
|
||||
),
|
||||
));
|
||||
}
|
||||
if manifest.version != PROTOCOL_VERSION {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_version_incompatible",
|
||||
format!(
|
||||
"远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})",
|
||||
manifest.version
|
||||
),
|
||||
format!(
|
||||
"Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})",
|
||||
manifest.version
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Download & verify ───────────────────────────────────────
|
||||
|
||||
async fn download_and_verify(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
artifact_name: &str,
|
||||
artifacts: &BTreeMap<String, ArtifactMeta>,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let meta = artifacts.get(artifact_name).ok_or_else(|| {
|
||||
localized(
|
||||
"webdav.sync.manifest_missing_artifact",
|
||||
format!("manifest 中缺少 artifact: {artifact_name}"),
|
||||
format!("Manifest missing artifact: {artifact_name}"),
|
||||
)
|
||||
})?;
|
||||
let url = remote_file_url(settings, artifact_name)?;
|
||||
let (bytes, _) = get_bytes(&url, auth).await?.ok_or_else(|| {
|
||||
localized(
|
||||
"webdav.sync.remote_missing_artifact",
|
||||
format!("远端缺少 artifact 文件: {artifact_name}"),
|
||||
format!("Remote artifact file missing: {artifact_name}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Quick size check before expensive hash
|
||||
if bytes.len() as u64 != meta.size {
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_size_mismatch",
|
||||
format!(
|
||||
"artifact {artifact_name} 大小不匹配 (expected: {}, got: {})",
|
||||
meta.size,
|
||||
bytes.len(),
|
||||
),
|
||||
format!(
|
||||
"Artifact {artifact_name} size mismatch (expected: {}, got: {})",
|
||||
meta.size,
|
||||
bytes.len(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let actual_hash = sha256_hex(&bytes);
|
||||
if actual_hash != meta.sha256 {
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_hash_mismatch",
|
||||
format!(
|
||||
"artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)",
|
||||
meta.sha256.get(..8).unwrap_or(&meta.sha256),
|
||||
actual_hash.get(..8).unwrap_or(&actual_hash),
|
||||
),
|
||||
format!(
|
||||
"Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)",
|
||||
meta.sha256.get(..8).unwrap_or(&meta.sha256),
|
||||
actual_hash.get(..8).unwrap_or(&actual_hash),
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn apply_snapshot(
|
||||
db: &crate::database::Database,
|
||||
db_sql: &[u8],
|
||||
skills_zip: &[u8],
|
||||
) -> Result<(), AppError> {
|
||||
let sql_str = std::str::from_utf8(db_sql).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.sql_not_utf8",
|
||||
format!("SQL 非 UTF-8: {e}"),
|
||||
format!("SQL is not valid UTF-8: {e}"),
|
||||
)
|
||||
})?;
|
||||
let skills_backup = backup_current_skills()?;
|
||||
|
||||
// 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。
|
||||
restore_skills_zip(skills_zip)?;
|
||||
|
||||
if let Err(db_err) = db.import_sql_string(sql_str) {
|
||||
if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {
|
||||
return Err(localized(
|
||||
"webdav.sync.db_import_and_rollback_failed",
|
||||
format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"),
|
||||
format!(
|
||||
"Database import failed: {db_err}; skills rollback also failed: {rollback_err}"
|
||||
),
|
||||
));
|
||||
}
|
||||
return Err(db_err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Remote path helpers ─────────────────────────────────────
|
||||
|
||||
fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec<String> {
|
||||
let mut segs = Vec::new();
|
||||
segs.extend(path_segments(&settings.remote_root).map(str::to_string));
|
||||
segs.push(format!("v{PROTOCOL_VERSION}"));
|
||||
segs.extend(path_segments(&settings.profile).map(str::to_string));
|
||||
segs
|
||||
}
|
||||
|
||||
fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result<String, AppError> {
|
||||
let mut segs = remote_dir_segments(settings);
|
||||
segs.extend(path_segments(file_name).map(str::to_string));
|
||||
build_remote_url(&settings.base_url, &segs)
|
||||
}
|
||||
|
||||
fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
|
||||
auth_from_credentials(&settings.username, &settings.password)
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn artifact(sha256: &str, size: u64) -> ArtifactMeta {
|
||||
ArtifactMeta {
|
||||
sha256: sha256.to_string(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_is_stable() {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc123", 100));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def456", 200));
|
||||
|
||||
let id1 = compute_snapshot_id(&artifacts);
|
||||
let id2 = compute_snapshot_id(&artifacts);
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_changes_with_artifacts() {
|
||||
let mut a1 = BTreeMap::new();
|
||||
a1.insert("db.sql".to_string(), artifact("hash-a", 1));
|
||||
|
||||
let mut a2 = BTreeMap::new();
|
||||
a2.insert("db.sql".to_string(), artifact("hash-b", 1));
|
||||
|
||||
assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dir_segments_uses_v2() {
|
||||
let settings = WebDavSyncSettings {
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
let segs = remote_dir_segments(&settings);
|
||||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_is_correct() {
|
||||
let hash = sha256_hex(b"hello");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_best_effort_returns_true_on_success() {
|
||||
let mut settings = WebDavSyncSettings::default();
|
||||
let ok = persist_sync_success_best_effort(
|
||||
&mut settings,
|
||||
"hash".to_string(),
|
||||
Some("etag".to_string()),
|
||||
|_settings, _hash, _etag| Ok(()),
|
||||
);
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_best_effort_returns_false_on_error() {
|
||||
let mut settings = WebDavSyncSettings::default();
|
||||
let ok = persist_sync_success_best_effort(
|
||||
&mut settings,
|
||||
"hash".to_string(),
|
||||
None,
|
||||
|_settings, _hash, _etag| Err(AppError::Config("boom".to_string())),
|
||||
);
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
fn manifest_with(format: &str, version: u32) -> SyncManifest {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc", 1));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def", 2));
|
||||
SyncManifest {
|
||||
format: format.to_string(),
|
||||
version,
|
||||
device_name: "My MacBook".to_string(),
|
||||
created_at: "2026-02-12T00:00:00Z".to_string(),
|
||||
artifacts,
|
||||
snapshot_id: "snap-1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_supported_manifest() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION);
|
||||
assert!(validate_manifest_compat(&manifest).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_format() {
|
||||
let manifest = manifest_with("other-format", PROTOCOL_VERSION);
|
||||
assert!(validate_manifest_compat(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_version() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1);
|
||||
assert!(validate_manifest_compat(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_returns_none_for_blank_input() {
|
||||
assert_eq!(normalize_device_name(" \n\t "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_collapses_whitespace_and_drops_control_chars() {
|
||||
assert_eq!(
|
||||
normalize_device_name(" Mac\tBook \n Pro\u{0007} "),
|
||||
Some("Mac Book Pro".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_truncates_to_max_len() {
|
||||
let long = "a".repeat(80);
|
||||
assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_serialization_uses_device_name_only() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION);
|
||||
let value = serde_json::to_value(&manifest).expect("serialize manifest");
|
||||
assert!(
|
||||
value.get("deviceName").is_some(),
|
||||
"manifest should contain deviceName"
|
||||
);
|
||||
assert!(
|
||||
value.get("deviceId").is_none(),
|
||||
"manifest should not contain deviceId"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tempfile::{tempdir, TempDir};
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::DateTime;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::skill::SkillService;
|
||||
|
||||
use super::{io_context_localized, localized, REMOTE_SKILLS_ZIP};
|
||||
|
||||
/// Maximum total bytes allowed during zip extraction (512 MB).
|
||||
const MAX_EXTRACT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// Maximum number of entries allowed in a zip archive.
|
||||
const MAX_EXTRACT_ENTRIES: usize = 10_000;
|
||||
|
||||
pub(super) struct SkillsBackup {
|
||||
_tmp: TempDir,
|
||||
backup_dir: PathBuf,
|
||||
ssot_path: PathBuf,
|
||||
existed: bool,
|
||||
}
|
||||
|
||||
pub(super) fn zip_skills_ssot(dest_path: &Path) -> Result<(), AppError> {
|
||||
let source = SkillService::get_ssot_dir().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_ssot_dir_failed",
|
||||
format!("获取 Skills SSOT 目录失败: {e}"),
|
||||
format!("Failed to resolve Skills SSOT directory: {e}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let file = fs::File::create(dest_path).map_err(|e| AppError::io(dest_path, e))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.last_modified_time(DateTime::default());
|
||||
|
||||
if source.exists() {
|
||||
let canonical_root = fs::canonicalize(&source).unwrap_or_else(|_| source.clone());
|
||||
let mut visited = HashSet::new();
|
||||
mark_visited_dir(&canonical_root, &mut visited)?;
|
||||
zip_dir_recursive(
|
||||
&canonical_root,
|
||||
&canonical_root,
|
||||
&mut writer,
|
||||
options,
|
||||
&mut visited,
|
||||
)?;
|
||||
}
|
||||
|
||||
writer.finish().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_zip_write_failed",
|
||||
format!("写入 skills.zip 失败: {e}"),
|
||||
format!("Failed to write skills.zip: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"webdav.sync.skills_extract_tmpdir_failed",
|
||||
"创建 skills 解压临时目录失败",
|
||||
"Failed to create temporary directory for skills extraction",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
let zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
|
||||
fs::write(&zip_path, raw).map_err(|e| AppError::io(&zip_path, e))?;
|
||||
|
||||
let file = fs::File::open(&zip_path).map_err(|e| AppError::io(&zip_path, e))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_zip_parse_failed",
|
||||
format!("解析 skills.zip 失败: {e}"),
|
||||
format!("Failed to parse skills.zip: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let extracted = tmp.path().join("skills-extracted");
|
||||
fs::create_dir_all(&extracted).map_err(|e| AppError::io(&extracted, e))?;
|
||||
|
||||
if archive.len() > MAX_EXTRACT_ENTRIES {
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_many_entries",
|
||||
format!("skills.zip 条目数过多({}),上限 {MAX_EXTRACT_ENTRIES}", archive.len()),
|
||||
format!("skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}", archive.len()),
|
||||
));
|
||||
}
|
||||
|
||||
let mut total_bytes: u64 = 0;
|
||||
for idx in 0..archive.len() {
|
||||
let mut entry = archive.by_index(idx).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_zip_entry_read_failed",
|
||||
format!("读取 ZIP 项失败: {e}"),
|
||||
format!("Failed to read ZIP entry: {e}"),
|
||||
)
|
||||
})?;
|
||||
let Some(safe_name) = entry.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
let out_path = extracted.join(safe_name);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&out_path).map_err(|e| AppError::io(&out_path, e))?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
let mut out = fs::File::create(&out_path).map_err(|e| AppError::io(&out_path, e))?;
|
||||
let written = std::io::copy(&mut entry, &mut out).map_err(|e| AppError::io(&out_path, e))?;
|
||||
total_bytes += written;
|
||||
if total_bytes > MAX_EXTRACT_BYTES {
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_large",
|
||||
format!("skills.zip 解压后体积超过上限({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024),
|
||||
format!("skills.zip extracted size exceeds limit ({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let ssot = SkillService::get_ssot_dir().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_ssot_dir_failed",
|
||||
format!("获取 Skills SSOT 目录失败: {e}"),
|
||||
format!("Failed to resolve Skills SSOT directory: {e}"),
|
||||
)
|
||||
})?;
|
||||
let bak = ssot.with_extension("bak");
|
||||
|
||||
if ssot.exists() {
|
||||
if bak.exists() {
|
||||
let _ = fs::remove_dir_all(&bak);
|
||||
}
|
||||
fs::rename(&ssot, &bak).map_err(|e| AppError::io(&ssot, e))?;
|
||||
}
|
||||
|
||||
if let Err(e) = copy_dir_recursive(&extracted, &ssot) {
|
||||
if bak.exists() {
|
||||
let _ = fs::remove_dir_all(&ssot);
|
||||
let _ = fs::rename(&bak, &ssot);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&bak);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn backup_current_skills() -> Result<SkillsBackup, AppError> {
|
||||
let ssot = SkillService::get_ssot_dir().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_ssot_dir_failed",
|
||||
format!("获取 Skills SSOT 目录失败: {e}"),
|
||||
format!("Failed to resolve Skills SSOT directory: {e}"),
|
||||
)
|
||||
})?;
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"webdav.sync.skills_backup_tmpdir_failed",
|
||||
"创建 skills 备份临时目录失败",
|
||||
"Failed to create temporary directory for skills backup",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
let backup_dir = tmp.path().join("skills-backup");
|
||||
|
||||
let existed = ssot.exists();
|
||||
if existed {
|
||||
copy_dir_recursive(&ssot, &backup_dir)?;
|
||||
}
|
||||
|
||||
Ok(SkillsBackup {
|
||||
_tmp: tmp,
|
||||
backup_dir,
|
||||
ssot_path: ssot,
|
||||
existed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn restore_skills_from_backup(backup: &SkillsBackup) -> Result<(), AppError> {
|
||||
if backup.ssot_path.exists() {
|
||||
fs::remove_dir_all(&backup.ssot_path).map_err(|e| AppError::io(&backup.ssot_path, e))?;
|
||||
}
|
||||
|
||||
if backup.existed {
|
||||
copy_dir_recursive(&backup.backup_dir, &backup.ssot_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn zip_dir_recursive(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
writer: &mut zip::ZipWriter<fs::File>,
|
||||
options: SimpleFileOptions,
|
||||
visited: &mut HashSet<PathBuf>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut entries: Vec<_> = fs::read_dir(current)
|
||||
.map_err(|e| AppError::io(current, e))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| AppError::io(current, e))?;
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
if name_str.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let real_path = match fs::canonicalize(&path) {
|
||||
Ok(p) if p.starts_with(root) => p,
|
||||
Ok(_) => {
|
||||
log::warn!(
|
||||
"[WebDAV] Skipping symlink outside skills root: {}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(_) => path.clone(),
|
||||
};
|
||||
|
||||
let rel = real_path
|
||||
.strip_prefix(root)
|
||||
.or_else(|_| path.strip_prefix(root))
|
||||
.map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.zip_relative_path_failed",
|
||||
format!("生成 ZIP 相对路径失败: {e}"),
|
||||
format!("Failed to build relative ZIP path: {e}"),
|
||||
)
|
||||
})?;
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
|
||||
if real_path.is_dir() {
|
||||
if !mark_visited_dir(&real_path, visited)? {
|
||||
log::warn!(
|
||||
"[WebDAV] Skipping already visited directory: {}",
|
||||
real_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
writer
|
||||
.add_directory(format!("{rel_str}/"), options)
|
||||
.map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.zip_add_directory_failed",
|
||||
format!("写入 ZIP 目录失败: {e}"),
|
||||
format!("Failed to write ZIP directory entry: {e}"),
|
||||
)
|
||||
})?;
|
||||
zip_dir_recursive(root, &real_path, writer, options, visited)?;
|
||||
} else {
|
||||
writer.start_file(&rel_str, options).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.zip_start_file_failed",
|
||||
format!("写入 ZIP 文件头失败: {e}"),
|
||||
format!("Failed to start ZIP file entry: {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut file = fs::File::open(&real_path).map_err(|e| AppError::io(&real_path, e))?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| AppError::io(&real_path, e))?;
|
||||
writer.write_all(&buf).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.zip_write_file_failed",
|
||||
format!("写入 ZIP 文件内容失败: {e}"),
|
||||
format!("Failed to write ZIP file content: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), AppError> {
|
||||
let mut visited = HashSet::new();
|
||||
copy_dir_recursive_inner(src, dest, &mut visited)
|
||||
}
|
||||
|
||||
fn copy_dir_recursive_inner(
|
||||
src: &Path,
|
||||
dest: &Path,
|
||||
visited: &mut HashSet<PathBuf>,
|
||||
) -> Result<(), AppError> {
|
||||
if !src.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if !mark_visited_dir(src, visited)? {
|
||||
log::warn!(
|
||||
"[WebDAV] Skipping already visited copy path: {}",
|
||||
src.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
fs::create_dir_all(dest).map_err(|e| AppError::io(dest, e))?;
|
||||
for entry in fs::read_dir(src).map_err(|e| AppError::io(src, e))? {
|
||||
let entry = entry.map_err(|e| AppError::io(src, e))?;
|
||||
let path = entry.path();
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
if path.is_dir() {
|
||||
copy_dir_recursive_inner(&path, &dest_path, visited)?;
|
||||
} else {
|
||||
fs::copy(&path, &dest_path).map_err(|e| AppError::io(&dest_path, e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_visited_dir(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<bool, AppError> {
|
||||
let canonical = fs::canonicalize(path).map_err(|e| AppError::io(path, e))?;
|
||||
Ok(visited.insert(canonical))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mark_visited_dir;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn mark_visited_dir_tracks_canonical_duplicates() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let dir = temp.path().join("skills");
|
||||
std::fs::create_dir_all(&dir).expect("create dir");
|
||||
|
||||
let mut visited = HashSet::new();
|
||||
assert!(mark_visited_dir(&dir, &mut visited).expect("first visit"));
|
||||
assert!(!mark_visited_dir(&dir, &mut visited).expect("second visit"));
|
||||
}
|
||||
}
|
||||
+210
-1
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
@@ -33,6 +34,8 @@ pub struct VisibleApps {
|
||||
pub gemini: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub opencode: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
impl Default for VisibleApps {
|
||||
@@ -42,6 +45,7 @@ impl Default for VisibleApps {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,10 +58,106 @@ impl VisibleApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => self.openclaw,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDAV 同步状态(持久化同步进度信息)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavSyncStatus {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_sync_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_remote_etag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_local_manifest_hash: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_remote_manifest_hash: Option<String>,
|
||||
}
|
||||
|
||||
fn default_remote_root() -> String {
|
||||
"cc-switch-sync".to_string()
|
||||
}
|
||||
fn default_profile() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
/// WebDAV v2 同步设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavSyncSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default = "default_remote_root")]
|
||||
pub remote_root: String,
|
||||
#[serde(default = "default_profile")]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub status: WebDavSyncStatus,
|
||||
}
|
||||
|
||||
impl Default for WebDavSyncSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
base_url: String::new(),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
remote_root: default_remote_root(),
|
||||
profile: default_profile(),
|
||||
status: WebDavSyncStatus::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebDavSyncSettings {
|
||||
pub fn validate(&self) -> Result<(), crate::error::AppError> {
|
||||
if self.base_url.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"webdav.base_url.required",
|
||||
"WebDAV 地址不能为空",
|
||||
"WebDAV URL is required.",
|
||||
));
|
||||
}
|
||||
if self.username.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"webdav.username.required",
|
||||
"WebDAV 用户名不能为空",
|
||||
"WebDAV username is required.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn normalize(&mut self) {
|
||||
self.base_url = self.base_url.trim().to_string();
|
||||
self.username = self.username.trim().to_string();
|
||||
self.remote_root = self.remote_root.trim().to_string();
|
||||
self.profile = self.profile.trim().to_string();
|
||||
if self.remote_root.is_empty() {
|
||||
self.remote_root = default_remote_root();
|
||||
}
|
||||
if self.profile.is_empty() {
|
||||
self.profile = default_profile();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if all credential fields are blank (no config to persist).
|
||||
fn is_empty(&self) -> bool {
|
||||
self.base_url.is_empty() && self.username.is_empty() && self.password.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用设置结构
|
||||
///
|
||||
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
|
||||
@@ -98,6 +198,8 @@ pub struct AppSettings {
|
||||
pub gemini_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openclaw_config_dir: Option<String>,
|
||||
|
||||
// ===== 当前供应商 ID(设备级)=====
|
||||
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
@@ -112,12 +214,23 @@ pub struct AppSettings {
|
||||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_opencode: Option<String>,
|
||||
/// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_openclaw: Option<String>,
|
||||
|
||||
// ===== Skill 同步设置 =====
|
||||
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||
#[serde(default)]
|
||||
pub skill_sync_method: SyncMethod,
|
||||
|
||||
// ===== WebDAV 同步设置 =====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_sync: Option<WebDavSyncSettings>,
|
||||
|
||||
// ===== WebDAV 备份设置(旧版,保留向后兼容)=====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_backup: Option<serde_json::Value>,
|
||||
|
||||
// ===== 终端设置 =====
|
||||
/// 首选终端应用(可选,默认使用系统默认终端)
|
||||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
@@ -150,11 +263,15 @@ impl Default for AppSettings {
|
||||
codex_config_dir: None,
|
||||
gemini_config_dir: None,
|
||||
opencode_config_dir: None,
|
||||
openclaw_config_dir: None,
|
||||
current_provider_claude: None,
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
current_provider_opencode: None,
|
||||
current_provider_openclaw: None,
|
||||
skill_sync_method: SyncMethod::default(),
|
||||
webdav_sync: None,
|
||||
webdav_backup: None,
|
||||
preferred_terminal: None,
|
||||
}
|
||||
}
|
||||
@@ -199,12 +316,26 @@ impl AppSettings {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.openclaw_config_dir = self
|
||||
.openclaw_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.language = self
|
||||
.language
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(sync) = &mut self.webdav_sync {
|
||||
sync.normalize();
|
||||
if sync.is_empty() {
|
||||
self.webdav_sync = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_file() -> Self {
|
||||
@@ -245,7 +376,27 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
|
||||
|
||||
let json = serde_json::to_string_pretty(&normalized)
|
||||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| AppError::io(&path, e))?;
|
||||
file.write_all(json.as_bytes())
|
||||
.map_err(|e| AppError::io(&path, e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -283,6 +434,15 @@ pub fn get_settings() -> AppSettings {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn get_settings_for_frontend() -> AppSettings {
|
||||
let mut settings = get_settings();
|
||||
if let Some(sync) = &mut settings.webdav_sync {
|
||||
sync.password.clear();
|
||||
}
|
||||
settings.webdav_backup = None;
|
||||
settings
|
||||
}
|
||||
|
||||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
new_settings.normalize_paths();
|
||||
save_settings_file(&new_settings)?;
|
||||
@@ -295,6 +455,22 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mutate_settings<F>(mutator: F) -> Result<(), AppError>
|
||||
where
|
||||
F: FnOnce(&mut AppSettings),
|
||||
{
|
||||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
});
|
||||
let mut next = guard.clone();
|
||||
mutator(&mut next);
|
||||
next.normalize_paths();
|
||||
save_settings_file(&next)?;
|
||||
*guard = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从文件重新加载设置到内存缓存
|
||||
/// 用于导入配置等场景,确保内存缓存与文件同步
|
||||
pub fn reload_settings() -> Result<(), AppError> {
|
||||
@@ -339,6 +515,14 @@ pub fn get_opencode_override_dir() -> Option<PathBuf> {
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_openclaw_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
.openclaw_config_dir
|
||||
.as_ref()
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
// ===== 当前供应商管理函数 =====
|
||||
|
||||
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
|
||||
@@ -352,6 +536,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
AppType::Codex => settings.current_provider_codex.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +552,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
||||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),
|
||||
}
|
||||
|
||||
update_settings(settings)
|
||||
@@ -433,3 +619,26 @@ pub fn get_preferred_terminal() -> Option<String> {
|
||||
.preferred_terminal
|
||||
.clone()
|
||||
}
|
||||
|
||||
// ===== WebDAV 同步设置管理函数 =====
|
||||
|
||||
/// 获取 WebDAV 同步设置
|
||||
pub fn get_webdav_sync_settings() -> Option<WebDavSyncSettings> {
|
||||
settings_store().read().ok()?.webdav_sync.clone()
|
||||
}
|
||||
|
||||
/// 保存 WebDAV 同步设置
|
||||
pub fn set_webdav_sync_settings(settings: Option<WebDavSyncSettings>) -> Result<(), AppError> {
|
||||
mutate_settings(|current| {
|
||||
current.webdav_sync = settings;
|
||||
})
|
||||
}
|
||||
|
||||
/// 仅更新 WebDAV 同步状态,避免覆写 credentials/root/profile 等字段
|
||||
pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> {
|
||||
mutate_settings(|current| {
|
||||
if let Some(sync) = current.webdav_sync.as_mut() {
|
||||
sync.status = status;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user