merge: integrate main with OpenClaw support and resolve skill conflicts

This commit is contained in:
YoVinchen
2026-02-14 17:21:08 +08:00
89 changed files with 9163 additions and 245 deletions
+37 -5
View File
@@ -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
}
}
@@ -238,6 +242,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 {
@@ -250,6 +257,7 @@ impl Default for McpRoot {
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
}
}
}
@@ -272,6 +280,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};
@@ -287,6 +297,7 @@ pub enum AppType {
Codex,
Gemini,
OpenCode,
OpenClaw,
}
impl AppType {
@@ -296,15 +307,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
@@ -314,6 +326,7 @@ impl AppType {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
]
.into_iter()
}
@@ -329,10 +342,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."),
)),
}
}
@@ -352,6 +366,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 {
@@ -362,6 +379,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(),
}
}
@@ -372,6 +390,7 @@ impl CommonConfigSnippets {
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
}
}
}
@@ -412,6 +431,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,
@@ -571,6 +591,7 @@ impl MultiAppConfig {
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
}
}
@@ -581,6 +602,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,
}
}
@@ -595,6 +617,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)
}
@@ -615,6 +638,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);
}
@@ -627,6 +651,7 @@ impl MultiAppConfig {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -697,6 +722,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);
@@ -725,12 +751,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 {
+11
View File
@@ -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() {
+12 -17
View File
@@ -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>(
+7
View File
@@ -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::*;
+108
View File
@@ -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())
}
+4
View File
@@ -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
// ============================================================================
+66 -2
View File
@@ -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> {
+97
View File
@@ -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")
);
}
}
+357
View File
@@ -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/");
}
}
+60
View File
@@ -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}"))
}
+13 -2
View File
@@ -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)?;
+9
View File
@@ -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");
+4
View File
@@ -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}"
+75
View File
@@ -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
+36 -1
View File
@@ -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,
@@ -886,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,
@@ -987,6 +1006,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,
@@ -999,6 +1031,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
+546
View File
@@ -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)
}
+3
View File
@@ -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
View File
@@ -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())
}
}
}
+2
View File
@@ -113,6 +113,8 @@ pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub opencode: bool,
pub openclaw: bool,
}
/// API 格式类型(预留,当前不需要格式转换)
+4
View File
@@ -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(())
+9
View File
@@ -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(())
}
+2
View File
@@ -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;
+150 -17
View File
@@ -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(())
}
+127 -53
View File
@@ -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(&current_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(), &current_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(&current_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(), &current_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))
}
}
}
}
+38
View File
@@ -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(())
}
}
}
+6
View File
@@ -284,6 +284,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"));
}
}
}
// 默认路径:回退到用户主目录下的标准位置
@@ -298,6 +303,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"),
})
}
+28
View File
@@ -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
+507
View File
@@ -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"));
}
}
+649
View File
@@ -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
View File
@@ -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;
}
})
}