mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 01:25:33 +08:00
merge: integrate main with OpenClaw support and resolve skill conflicts
This commit is contained in:
@@ -126,6 +126,10 @@ impl ConfigService {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses additive mode, no live sync needed
|
||||
// OpenClaw providers are managed directly in the config file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -123,6 +123,11 @@ impl McpService {
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw MCP support is still in development (Issue #4834)
|
||||
// Skip for now
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping sync");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -148,6 +153,10 @@ impl McpService {
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw MCP support is still in development
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping remove");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
pub mod usage_stats;
|
||||
pub mod webdav;
|
||||
pub mod webdav_sync;
|
||||
|
||||
pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
|
||||
@@ -191,6 +191,48 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
}
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses additive mode - write provider to config
|
||||
use crate::openclaw_config;
|
||||
use crate::openclaw_config::OpenClawProviderConfig;
|
||||
|
||||
// Convert settings_config to OpenClawProviderConfig
|
||||
let openclaw_config_result =
|
||||
serde_json::from_value::<OpenClawProviderConfig>(provider.settings_config.clone());
|
||||
|
||||
match openclaw_config_result {
|
||||
Ok(config) => {
|
||||
openclaw_config::set_typed_provider(&provider.id, &config)?;
|
||||
log::info!("OpenClaw provider '{}' written to live config", provider.id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to parse OpenClaw provider config for '{}': {}",
|
||||
provider.id,
|
||||
e
|
||||
);
|
||||
// Try to write as raw JSON if it looks valid
|
||||
if provider.settings_config.get("baseUrl").is_some()
|
||||
|| provider.settings_config.get("api").is_some()
|
||||
|| provider.settings_config.get("models").is_some()
|
||||
{
|
||||
openclaw_config::set_provider(
|
||||
&provider.id,
|
||||
provider.settings_config.clone(),
|
||||
)?;
|
||||
log::info!(
|
||||
"OpenClaw provider '{}' written as raw JSON to live config",
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenClaw provider '{}' has invalid config structure, skipping write",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -340,6 +382,21 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
|
||||
|
||||
let config_path = get_openclaw_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"openclaw.config.missing",
|
||||
"OpenClaw 配置文件不存在",
|
||||
"OpenClaw configuration file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let config = read_openclaw_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +405,12 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
/// Returns `Ok(true)` if a provider was actually imported,
|
||||
/// `Ok(false)` if skipped (providers already exist for this app).
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
// Additive mode apps (OpenCode, OpenClaw) should use their dedicated
|
||||
// import_xxx_providers_from_live functions, not this generic default config import
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
{
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if !providers.is_empty() {
|
||||
@@ -415,23 +478,9 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - import from live is not the same pattern
|
||||
// For now, return an empty config structure
|
||||
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||
|
||||
let config_path = get_opencode_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"opencode.live.missing",
|
||||
"OpenCode 配置文件不存在",
|
||||
"OpenCode configuration file is missing",
|
||||
));
|
||||
}
|
||||
|
||||
// For OpenCode, we return the full config - but note that OpenCode
|
||||
// uses additive mode, so importing defaults works differently
|
||||
read_opencode_config()?
|
||||
// OpenCode and OpenClaw use additive mode and are handled by early return above
|
||||
AppType::OpenCode | AppType::OpenClaw => {
|
||||
unreachable!("additive mode apps are handled by early return")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -609,3 +658,87 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Import all providers from OpenClaw live config to database
|
||||
///
|
||||
/// This imports existing providers from ~/.openclaw/openclaw.json
|
||||
/// into the CC Switch database. Each provider found will be added to the
|
||||
/// database with is_current set to false.
|
||||
pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, AppError> {
|
||||
use crate::openclaw_config;
|
||||
|
||||
let providers = openclaw_config::get_typed_providers()?;
|
||||
if providers.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut imported = 0;
|
||||
let existing = state.db.get_all_providers("openclaw")?;
|
||||
|
||||
for (id, config) in providers {
|
||||
// Validate: skip entries with empty id or no models
|
||||
if id.trim().is_empty() {
|
||||
log::warn!("Skipping OpenClaw provider with empty id");
|
||||
continue;
|
||||
}
|
||||
if config.models.is_empty() {
|
||||
log::warn!("Skipping OpenClaw provider '{id}': no models defined");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already exists in database
|
||||
if existing.contains_key(&id) {
|
||||
log::debug!("OpenClaw provider '{id}' already exists in database, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to Value for settings_config
|
||||
let settings_config = match serde_json::to_value(&config) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine display name: use first model name if available, otherwise use id
|
||||
let display_name = config
|
||||
.models
|
||||
.first()
|
||||
.and_then(|m| m.name.clone())
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
imported += 1;
|
||||
log::info!("Imported OpenClaw provider '{id}' from live config");
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Remove an OpenClaw provider from live config
|
||||
///
|
||||
/// This removes a specific provider from ~/.openclaw/openclaw.json
|
||||
/// without affecting other providers in the file.
|
||||
pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppError> {
|
||||
use crate::openclaw_config;
|
||||
|
||||
// Check if OpenClaw config directory exists
|
||||
if !openclaw_config::get_openclaw_dir().exists() {
|
||||
log::debug!("OpenClaw config directory doesn't exist, skipping removal of '{provider_id}'");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
openclaw_config::remove_provider(provider_id)?;
|
||||
log::info!("OpenClaw provider '{provider_id}' removed from live config");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ use crate::store::AppState;
|
||||
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{
|
||||
import_default_config, import_opencode_providers_from_live, read_live_settings,
|
||||
sync_current_to_live,
|
||||
import_default_config, import_openclaw_providers_from_live,
|
||||
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
@@ -30,7 +30,9 @@ pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||
use live::{
|
||||
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
|
||||
};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
/// Provider business logic service
|
||||
@@ -142,10 +144,10 @@ impl ProviderService {
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||
///
|
||||
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||
/// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。
|
||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||
// OpenCode uses additive mode - no "current" provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps have no "current" provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||
@@ -162,10 +164,12 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// OpenCode uses additive mode - always write to live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
||||
if app_type.is_additive_mode() {
|
||||
// OMO providers use exclusive mode and write to dedicated config file.
|
||||
if provider.category.as_deref() == Some("omo") {
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
// Do not auto-enable newly added OMO providers.
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
@@ -201,9 +205,11 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// OpenCode uses additive mode - always update in live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
if provider.category.as_deref() == Some("omo") {
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
let is_omo_current = state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
|
||||
@@ -253,43 +259,48 @@ impl ProviderService {
|
||||
/// Delete a provider
|
||||
///
|
||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
/// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// OpenCode uses additive mode - no current provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let is_omo = state
|
||||
.db
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
|
||||
if is_omo {
|
||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||
let omo_count = state
|
||||
// Additive mode apps - no current provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let is_omo = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
|
||||
if omo_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
if is_omo {
|
||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||
let omo_count = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
if omo_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
// Also remove from live config
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
match app_type {
|
||||
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
|
||||
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
|
||||
_ => {} // Should not reach here
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -306,7 +317,7 @@ impl ProviderService {
|
||||
state.db.delete_provider(app_type.as_str(), id)
|
||||
}
|
||||
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)
|
||||
///
|
||||
/// Does NOT delete from database - provider remains in the list.
|
||||
/// This is used when user wants to "remove" a provider from active config
|
||||
@@ -338,7 +349,9 @@ impl ProviderService {
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
}
|
||||
}
|
||||
// Future: add other additive mode apps here
|
||||
AppType::OpenClaw => {
|
||||
remove_openclaw_provider_from_live(id)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Message(format!(
|
||||
"App {} does not support remove from live config",
|
||||
@@ -454,22 +467,25 @@ impl ProviderService {
|
||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
|
||||
match (current_id, matches!(app_type, AppType::OpenCode)) {
|
||||
(Some(current_id), false) if current_id != id => {
|
||||
// Only backfill when switching to a different provider.
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow.
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
if let Some(current_id) = current_id {
|
||||
if current_id != id {
|
||||
// Additive mode apps - all providers coexist in the same file,
|
||||
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||
if !app_type.is_additive_mode() {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps skip setting is_current (no such concept)
|
||||
if !app_type.is_additive_mode() {
|
||||
// Update local settings (device-level, takes priority)
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
|
||||
@@ -515,6 +531,7 @@ impl ProviderService {
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +545,7 @@ impl ProviderService {
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +702,27 @@ impl ProviderService {
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenClaw (JSON format)
|
||||
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
|
||||
// For common config, we exclude provider-specific fields like apiKey
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Remove provider-specific fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
obj.remove("apiKey");
|
||||
obj.remove("baseUrl");
|
||||
// Keep api and models as they might be common
|
||||
}
|
||||
|
||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
///
|
||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||
@@ -861,6 +900,17 @@ impl ProviderService {
|
||||
));
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses config structure: { baseUrl, apiKey, api, models }
|
||||
// Basic validation - must be an object
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err(AppError::localized(
|
||||
"provider.openclaw.settings.not_object",
|
||||
"OpenClaw 配置必须是 JSON 对象",
|
||||
"OpenClaw configuration must be a JSON object",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clean UsageScript configuration (common for all app types)
|
||||
@@ -1032,6 +1082,30 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses apiKey and baseUrl directly on the object
|
||||
let api_key = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.openclaw.api_key.missing",
|
||||
"缺少 API Key",
|
||||
"API key is missing",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let base_url = provider
|
||||
.settings_config
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,11 +210,16 @@ impl ProxyService {
|
||||
.await
|
||||
.map(|c| c.enabled)
|
||||
.unwrap_or(false);
|
||||
// OpenCode and OpenClaw don't support proxy features, always return false
|
||||
let opencode_enabled = false;
|
||||
let openclaw_enabled = false;
|
||||
|
||||
Ok(ProxyTakeoverStatus {
|
||||
claude: claude_enabled,
|
||||
codex: codex_enabled,
|
||||
gemini: gemini_enabled,
|
||||
opencode: opencode_enabled,
|
||||
openclaw: openclaw_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -372,6 +377,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
self.sync_live_config_to_provider(app_type, &live_config)
|
||||
@@ -588,6 +597,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -770,6 +782,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
@@ -982,6 +998,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1068,6 +1088,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1103,6 +1126,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1186,6 +1212,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
Err("OpenCode 不支持代理功能".to_string())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
Err("OpenClaw 不支持代理功能".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,6 +1237,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1250,6 +1284,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,14 @@ impl StreamCheckService {
|
||||
"OpenCode does not support health check yet",
|
||||
));
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support stream check yet
|
||||
return Err(AppError::localized(
|
||||
"openclaw_no_stream_check",
|
||||
"OpenClaw 暂不支持健康检查",
|
||||
"OpenClaw does not support health check yet",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
@@ -567,6 +575,11 @@ impl StreamCheckService {
|
||||
// Try to extract first model from the models object
|
||||
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses models array in settings_config
|
||||
// Try to extract first model from the models array
|
||||
Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,6 +593,21 @@ impl StreamCheckService {
|
||||
models.keys().next().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_openclaw_model(provider: &Provider) -> Option<String> {
|
||||
// OpenClaw uses models array: [{ "id": "model-id", "name": "Model Name" }]
|
||||
let models = provider
|
||||
.settings_config
|
||||
.get("models")
|
||||
.and_then(|m| m.as_array())?;
|
||||
|
||||
// Return the first model ID from the models array
|
||||
models
|
||||
.first()
|
||||
.and_then(|m| m.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! WebDAV HTTP transport layer.
|
||||
//!
|
||||
//! Low-level HTTP primitives for WebDAV operations (PUT, GET, HEAD, MKCOL, PROPFIND).
|
||||
//! The sync protocol logic lives in [`super::webdav_sync`].
|
||||
|
||||
use reqwest::{Method, RequestBuilder, StatusCode, Url};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::http_client;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
/// Timeout for large file transfers (PUT/GET of db.sql, skills.zip).
|
||||
const TRANSFER_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Auth pair: `(username, Some(password))`.
|
||||
pub type WebDavAuth = Option<(String, Option<String>)>;
|
||||
|
||||
// ─── WebDAV extension methods ────────────────────────────────
|
||||
|
||||
fn method_propfind() -> Method {
|
||||
Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method")
|
||||
}
|
||||
|
||||
fn method_mkcol() -> Method {
|
||||
Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method")
|
||||
}
|
||||
|
||||
// ─── URL utilities ───────────────────────────────────────────
|
||||
|
||||
/// Parse and validate a WebDAV base URL (must be http or https).
|
||||
pub fn parse_base_url(raw: &str) -> Result<Url, AppError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"webdav.base_url.required",
|
||||
"WebDAV 地址不能为空",
|
||||
"WebDAV URL is required.",
|
||||
));
|
||||
}
|
||||
let url = Url::parse(trimmed).map_err(|e| {
|
||||
AppError::localized(
|
||||
"webdav.base_url.invalid",
|
||||
format!("WebDAV 地址无效: {e}"),
|
||||
format!("Invalid WebDAV URL: {e}"),
|
||||
)
|
||||
})?;
|
||||
match url.scheme() {
|
||||
"http" | "https" => Ok(url),
|
||||
_ => Err(AppError::localized(
|
||||
"webdav.base_url.scheme_invalid",
|
||||
"WebDAV 仅支持 http/https 地址",
|
||||
"WebDAV URL must use http or https.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a full URL from a base URL string and path segments.
|
||||
///
|
||||
/// Each segment is individually percent-encoded by the `url` crate.
|
||||
pub fn build_remote_url(base_url: &str, segments: &[String]) -> Result<String, AppError> {
|
||||
let mut url = parse_base_url(base_url)?;
|
||||
{
|
||||
let mut path = url.path_segments_mut().map_err(|_| {
|
||||
AppError::localized(
|
||||
"webdav.base_url.unusable",
|
||||
"WebDAV 地址格式不支持追加路径",
|
||||
"WebDAV URL format does not support appending path segments.",
|
||||
)
|
||||
})?;
|
||||
path.pop_if_empty();
|
||||
for seg in segments {
|
||||
path.push(seg);
|
||||
}
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Split a slash-delimited path into non-empty segments.
|
||||
pub fn path_segments(raw: &str) -> impl Iterator<Item = &str> {
|
||||
raw.trim_matches('/').split('/').filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────
|
||||
|
||||
/// Build auth from username/password. Returns `None` if username is blank.
|
||||
pub fn auth_from_credentials(username: &str, password: &str) -> WebDavAuth {
|
||||
let user = username.trim();
|
||||
if user.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((user.to_string(), Some(password.to_string())))
|
||||
}
|
||||
|
||||
/// Apply Basic-Auth to a request builder if auth is present.
|
||||
fn apply_auth(builder: RequestBuilder, auth: &WebDavAuth) -> RequestBuilder {
|
||||
match auth {
|
||||
Some((user, pass)) => builder.basic_auth(user, pass.as_deref()),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
|
||||
fn webdav_transport_error(
|
||||
key: &'static str,
|
||||
op_zh: &str,
|
||||
op_en: &str,
|
||||
target_url: &str,
|
||||
err: &reqwest::Error,
|
||||
) -> AppError {
|
||||
let (zh_reason, en_reason) = if err.is_timeout() {
|
||||
("请求超时", "request timed out")
|
||||
} else if err.is_connect() {
|
||||
("连接失败", "connection failed")
|
||||
} else if err.is_request() {
|
||||
("请求构造失败", "request build failed")
|
||||
} else {
|
||||
("网络请求失败", "network request failed")
|
||||
};
|
||||
|
||||
let safe_url = redact_url(target_url);
|
||||
AppError::localized(
|
||||
key,
|
||||
format!("WebDAV {op_zh}失败({zh_reason}): {safe_url}"),
|
||||
format!("WebDAV {op_en} failed ({en_reason}): {safe_url}"),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── HTTP operations ─────────────────────────────────────────
|
||||
|
||||
/// Test WebDAV connectivity via PROPFIND Depth=0 on the base URL.
|
||||
pub async fn test_connection(base_url: &str, auth: &WebDavAuth) -> Result<(), AppError> {
|
||||
let url = parse_base_url(base_url)?;
|
||||
let client = http_client::get();
|
||||
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.request(method_propfind(), url)
|
||||
.header("Depth", "0")
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.connection_failed",
|
||||
"连接",
|
||||
"connection",
|
||||
base_url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status().is_success() || resp.status() == StatusCode::MULTI_STATUS {
|
||||
return Ok(());
|
||||
}
|
||||
Err(webdav_status_error("PROPFIND", resp.status(), base_url))
|
||||
}
|
||||
|
||||
/// Ensure a chain of remote directories exists.
|
||||
///
|
||||
/// Uses optimistic MKCOL: try creating first, fall back to PROPFIND verification
|
||||
/// on ambiguous responses. This halves the round-trips vs PROPFIND-first approach.
|
||||
pub async fn ensure_remote_directories(
|
||||
base_url: &str,
|
||||
segments: &[String],
|
||||
auth: &WebDavAuth,
|
||||
) -> Result<(), AppError> {
|
||||
if segments.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let client = http_client::get();
|
||||
|
||||
for depth in 1..=segments.len() {
|
||||
let prefix = &segments[..depth];
|
||||
let url = build_remote_url(base_url, prefix)?;
|
||||
let dir_url = if url.ends_with('/') {
|
||||
url
|
||||
} else {
|
||||
format!("{url}/")
|
||||
};
|
||||
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.request(method_mkcol(), &dir_url)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.mkcol_failed",
|
||||
"MKCOL 请求",
|
||||
"MKCOL request",
|
||||
&dir_url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
match status {
|
||||
s if s == StatusCode::CREATED || s.is_success() => {
|
||||
log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url));
|
||||
}
|
||||
// 405 commonly means "already exists" on many WebDAV servers
|
||||
StatusCode::METHOD_NOT_ALLOWED => {}
|
||||
// Ambiguous — verify directory actually exists via PROPFIND
|
||||
s if s == StatusCode::CONFLICT || s.is_redirection() => {
|
||||
if !propfind_exists(&client, &dir_url, auth).await? {
|
||||
return Err(webdav_status_error("MKCOL", status, &dir_url));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(webdav_status_error("MKCOL", status, &dir_url));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PUT bytes to a remote WebDAV URL.
|
||||
pub async fn put_bytes(
|
||||
url: &str,
|
||||
auth: &WebDavAuth,
|
||||
bytes: Vec<u8>,
|
||||
content_type: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let client = http_client::get();
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.put(url)
|
||||
.header("Content-Type", content_type)
|
||||
.body(bytes)
|
||||
.timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.put_failed",
|
||||
"PUT 请求",
|
||||
"PUT request",
|
||||
url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(webdav_status_error("PUT", resp.status(), url))
|
||||
}
|
||||
|
||||
/// GET bytes from a remote WebDAV URL. Returns `None` on 404.
|
||||
///
|
||||
/// On success returns `(body_bytes, optional_etag)`.
|
||||
pub async fn get_bytes(
|
||||
url: &str,
|
||||
auth: &WebDavAuth,
|
||||
) -> Result<Option<(Vec<u8>, Option<String>)>, AppError> {
|
||||
let client = http_client::get();
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.get_failed",
|
||||
"GET 请求",
|
||||
"GET request",
|
||||
url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
return Err(webdav_status_error("GET", resp.status(), url));
|
||||
}
|
||||
let etag = resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::localized(
|
||||
"webdav.response_read_failed",
|
||||
format!("读取 WebDAV 响应失败: {e}"),
|
||||
format!("Failed to read WebDAV response: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Some((bytes.to_vec(), etag)))
|
||||
}
|
||||
|
||||
/// HEAD request to retrieve the ETag. Returns `None` on 404.
|
||||
pub async fn head_etag(url: &str, auth: &WebDavAuth) -> Result<Option<String>, AppError> {
|
||||
let client = http_client::get();
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.head(url)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
webdav_transport_error(
|
||||
"webdav.head_failed",
|
||||
"HEAD 请求",
|
||||
"HEAD request",
|
||||
url,
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
return Err(webdav_status_error("HEAD", resp.status(), url));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────
|
||||
|
||||
/// PROPFIND Depth=0 to check if a remote resource exists.
|
||||
async fn propfind_exists(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
auth: &WebDavAuth,
|
||||
) -> Result<bool, AppError> {
|
||||
let resp = apply_auth(
|
||||
client
|
||||
.request(method_propfind(), url)
|
||||
.header("Depth", "0")
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
|
||||
auth,
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(r) => Ok(r.status().is_success() || r.status() == StatusCode::MULTI_STATUS),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[WebDAV] PROPFIND check failed for {}: {e}",
|
||||
redact_url(url)
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Service detection & error helpers ───────────────────────
|
||||
|
||||
/// Check if a URL points to Jianguoyun (坚果云).
|
||||
pub fn is_jianguoyun(url: &str) -> bool {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
|
||||
.map(|host| host.contains("jianguoyun.com") || host.contains("nutstore"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build an `AppError` with service-specific hints for WebDAV failures.
|
||||
pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError {
|
||||
let safe_url = redact_url(url);
|
||||
let mut zh = format!("WebDAV {op} 失败: {status} ({safe_url})");
|
||||
let mut en = format!("WebDAV {op} failed: {status} ({safe_url})");
|
||||
let jgy = is_jianguoyun(url);
|
||||
|
||||
if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
||||
if jgy {
|
||||
zh.push_str(
|
||||
"。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。",
|
||||
);
|
||||
en.push_str(
|
||||
". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.",
|
||||
);
|
||||
} else {
|
||||
zh.push_str("。请检查 WebDAV 用户名、密码及目录读写权限。");
|
||||
en.push_str(". Please check WebDAV username/password and directory permissions.");
|
||||
}
|
||||
} else if jgy && (status == StatusCode::NOT_FOUND || status.is_redirection()) {
|
||||
zh.push_str("。坚果云常见原因:地址不在 /dav/ 可写目录下。");
|
||||
en.push_str(". Common Jianguoyun cause: URL is outside a writable /dav/ directory.");
|
||||
} else if op == "MKCOL" && status == StatusCode::CONFLICT {
|
||||
if jgy {
|
||||
zh.push_str(
|
||||
"。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。",
|
||||
);
|
||||
en.push_str(
|
||||
". Jianguoyun does not allow creating top-level folders automatically; create it manually first.",
|
||||
);
|
||||
} else {
|
||||
zh.push_str("。请确认上级目录存在。");
|
||||
en.push_str(". Please ensure the parent directory exists.");
|
||||
}
|
||||
}
|
||||
|
||||
AppError::localized("webdav.http.status", zh, en)
|
||||
}
|
||||
|
||||
fn redact_url(raw: &str) -> String {
|
||||
match Url::parse(raw) {
|
||||
Ok(mut parsed) => {
|
||||
let _ = parsed.set_username("");
|
||||
let _ = parsed.set_password(None);
|
||||
|
||||
let mut out = format!("{}://", parsed.scheme());
|
||||
if let Some(host) = parsed.host_str() {
|
||||
out.push_str(host);
|
||||
}
|
||||
if let Some(port) = parsed.port() {
|
||||
out.push(':');
|
||||
out.push_str(&port.to_string());
|
||||
}
|
||||
out.push_str(parsed.path());
|
||||
|
||||
let mut keys: Vec<String> = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
if !keys.is_empty() {
|
||||
out.push_str("?[keys:");
|
||||
out.push_str(&keys.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
out
|
||||
}
|
||||
Err(_) => raw.split('?').next().unwrap_or(raw).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_remote_url_encodes_path_segments() {
|
||||
let url = build_remote_url(
|
||||
"https://dav.example.com/remote.php/dav/files/demo/",
|
||||
&[
|
||||
"cc switch-sync".to_string(),
|
||||
"v2".to_string(),
|
||||
"default profile".to_string(),
|
||||
"manifest.json".to_string(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/default%20profile/manifest.json"
|
||||
);
|
||||
assert!(!url.contains("//cc"), "should not have double-slash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_jianguoyun_detects_correctly() {
|
||||
assert!(is_jianguoyun("https://dav.jianguoyun.com/dav"));
|
||||
assert!(is_jianguoyun("https://dav.jianguoyun.com/dav/folder"));
|
||||
assert!(!is_jianguoyun("https://nextcloud.example.com/dav"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_segments_splits_correctly() {
|
||||
let segs: Vec<_> = path_segments("/a/b/c/").collect();
|
||||
assert_eq!(segs, vec!["a", "b", "c"]);
|
||||
|
||||
let segs: Vec<_> = path_segments("single").collect();
|
||||
assert_eq!(segs, vec!["single"]);
|
||||
|
||||
let segs: Vec<_> = path_segments("").collect();
|
||||
assert!(segs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_from_credentials_trims_and_rejects_blank() {
|
||||
assert!(auth_from_credentials(" ", "pass").is_none());
|
||||
let auth = auth_from_credentials(" user ", "pass");
|
||||
assert_eq!(auth, Some(("user".to_string(), Some("pass".to_string()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_url_hides_credentials_and_query_values() {
|
||||
let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1");
|
||||
assert_eq!(
|
||||
redacted,
|
||||
"https://example.com:8443/dav?[keys:foo,token]"
|
||||
);
|
||||
assert!(!redacted.contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
//! WebDAV v2 sync protocol layer.
|
||||
//!
|
||||
//! Implements manifest-based synchronization on top of the HTTP transport
|
||||
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::webdav::{
|
||||
auth_from_credentials, build_remote_url, ensure_remote_directories, get_bytes, head_etag,
|
||||
path_segments, put_bytes, test_connection, WebDavAuth,
|
||||
};
|
||||
use crate::settings::{update_webdav_sync_status, WebDavSyncSettings, WebDavSyncStatus};
|
||||
|
||||
mod archive;
|
||||
use archive::{
|
||||
backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot,
|
||||
};
|
||||
|
||||
// ─── Protocol constants ──────────────────────────────────────
|
||||
|
||||
const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
|
||||
const PROTOCOL_VERSION: u32 = 2;
|
||||
const REMOTE_DB_SQL: &str = "db.sql";
|
||||
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
|
||||
const REMOTE_MANIFEST: &str = "manifest.json";
|
||||
const MAX_DEVICE_NAME_LEN: usize = 64;
|
||||
|
||||
fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {
|
||||
AppError::localized(key, zh, en)
|
||||
}
|
||||
|
||||
fn io_context_localized(
|
||||
_key: &'static str,
|
||||
zh: impl Into<String>,
|
||||
en: impl Into<String>,
|
||||
source: std::io::Error,
|
||||
) -> AppError {
|
||||
let zh_msg = zh.into();
|
||||
let en_msg = en.into();
|
||||
AppError::IoContext {
|
||||
context: format!("{zh_msg} ({en_msg})"),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncManifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
device_name: String,
|
||||
created_at: String,
|
||||
artifacts: BTreeMap<String, ArtifactMeta>,
|
||||
snapshot_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ArtifactMeta {
|
||||
sha256: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
struct LocalSnapshot {
|
||||
db_sql: Vec<u8>,
|
||||
skills_zip: Vec<u8>,
|
||||
manifest_bytes: Vec<u8>,
|
||||
manifest_hash: String,
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────
|
||||
|
||||
/// Check WebDAV connectivity and ensure remote directory structure.
|
||||
pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
test_connection(&settings.base_url, &auth).await?;
|
||||
let dir_segs = remote_dir_segments(settings);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload local snapshot (db + skills) to remote.
|
||||
pub async fn upload(
|
||||
db: &crate::database::Database,
|
||||
settings: &mut WebDavSyncSettings,
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
let dir_segs = remote_dir_segments(settings);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
|
||||
let snapshot = build_local_snapshot(db, settings)?;
|
||||
|
||||
// Upload order: artifacts first, manifest last (best-effort consistency)
|
||||
let db_url = remote_file_url(settings, REMOTE_DB_SQL)?;
|
||||
put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
|
||||
|
||||
let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?;
|
||||
put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
|
||||
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
put_bytes(
|
||||
&manifest_url,
|
||||
&auth,
|
||||
snapshot.manifest_bytes,
|
||||
"application/json",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fetch etag (best-effort, don't fail the upload)
|
||||
let etag = match head_etag(&manifest_url, &auth).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::debug!("[WebDAV] Failed to fetch ETag after upload: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let _persisted = persist_sync_success_best_effort(
|
||||
settings,
|
||||
snapshot.manifest_hash,
|
||||
etag,
|
||||
persist_sync_success,
|
||||
);
|
||||
Ok(serde_json::json!({ "status": "uploaded" }))
|
||||
}
|
||||
|
||||
/// Download remote snapshot and apply to local database + skills.
|
||||
pub async fn download(
|
||||
db: &crate::database::Database,
|
||||
settings: &mut WebDavSyncSettings,
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth).await?.ok_or_else(|| {
|
||||
localized(
|
||||
"webdav.sync.remote_empty",
|
||||
"远端没有可下载的同步数据",
|
||||
"No downloadable sync data found on the remote.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest: SyncManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
validate_manifest_compat(&manifest)?;
|
||||
|
||||
// Download and verify artifacts
|
||||
let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?;
|
||||
let skills_zip =
|
||||
download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?;
|
||||
|
||||
// Apply snapshot
|
||||
apply_snapshot(db, &db_sql, &skills_zip)?;
|
||||
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
let _persisted =
|
||||
persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success);
|
||||
Ok(serde_json::json!({ "status": "downloaded" }))
|
||||
}
|
||||
|
||||
/// Fetch remote manifest info without downloading artifacts.
|
||||
pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<Value>, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
|
||||
let Some((bytes, _)) = get_bytes(&manifest_url, &auth).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let compatible = validate_manifest_compat(&manifest).is_ok();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"deviceName": manifest.device_name,
|
||||
"createdAt": manifest.created_at,
|
||||
"snapshotId": manifest.snapshot_id,
|
||||
"version": manifest.version,
|
||||
"compatible": compatible,
|
||||
"artifacts": manifest.artifacts.keys().collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
// ─── Sync status persistence (I3: deduplicated) ─────────────
|
||||
|
||||
fn persist_sync_success(
|
||||
settings: &mut WebDavSyncSettings,
|
||||
manifest_hash: String,
|
||||
etag: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let status = WebDavSyncStatus {
|
||||
last_sync_at: Some(Utc::now().timestamp()),
|
||||
last_error: None,
|
||||
last_local_manifest_hash: Some(manifest_hash.clone()),
|
||||
last_remote_manifest_hash: Some(manifest_hash),
|
||||
last_remote_etag: etag,
|
||||
};
|
||||
settings.status = status.clone();
|
||||
update_webdav_sync_status(status)
|
||||
}
|
||||
|
||||
fn persist_sync_success_best_effort<F>(
|
||||
settings: &mut WebDavSyncSettings,
|
||||
manifest_hash: String,
|
||||
etag: Option<String>,
|
||||
persist_fn: F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnOnce(&mut WebDavSyncSettings, String, Option<String>) -> Result<(), AppError>,
|
||||
{
|
||||
match persist_fn(settings, manifest_hash, etag) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("[WebDAV] Persist sync status failed, keep operation success: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Snapshot building ───────────────────────────────────────
|
||||
|
||||
fn build_local_snapshot(
|
||||
db: &crate::database::Database,
|
||||
_settings: &WebDavSyncSettings,
|
||||
) -> Result<LocalSnapshot, AppError> {
|
||||
// Export database to SQL string
|
||||
let sql_string = db.export_sql_string()?;
|
||||
let db_sql = sql_string.into_bytes();
|
||||
|
||||
// Pack skills into deterministic ZIP
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"webdav.sync.snapshot_tmpdir_failed",
|
||||
"创建 WebDAV 快照临时目录失败",
|
||||
"Failed to create temporary directory for WebDAV snapshot",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
|
||||
zip_skills_ssot(&skills_zip_path)?;
|
||||
let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?;
|
||||
|
||||
// Build artifact map and compute hashes
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert(
|
||||
REMOTE_DB_SQL.to_string(),
|
||||
ArtifactMeta {
|
||||
sha256: sha256_hex(&db_sql),
|
||||
size: db_sql.len() as u64,
|
||||
},
|
||||
);
|
||||
artifacts.insert(
|
||||
REMOTE_SKILLS_ZIP.to_string(),
|
||||
ArtifactMeta {
|
||||
sha256: sha256_hex(&skills_zip),
|
||||
size: skills_zip.len() as u64,
|
||||
},
|
||||
);
|
||||
|
||||
let snapshot_id = compute_snapshot_id(&artifacts);
|
||||
let manifest = SyncManifest {
|
||||
format: PROTOCOL_FORMAT.to_string(),
|
||||
version: PROTOCOL_VERSION,
|
||||
device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
artifacts,
|
||||
snapshot_id,
|
||||
};
|
||||
let manifest_bytes =
|
||||
serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
|
||||
Ok(LocalSnapshot {
|
||||
db_sql,
|
||||
skills_zip,
|
||||
manifest_bytes,
|
||||
manifest_hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute a deterministic snapshot identity from artifact hashes.
|
||||
///
|
||||
/// BTreeMap iteration order is sorted by key, ensuring stability.
|
||||
fn compute_snapshot_id(artifacts: &BTreeMap<String, ArtifactMeta>) -> String {
|
||||
let parts: Vec<String> = artifacts
|
||||
.iter()
|
||||
.map(|(name, meta)| format!("{}:{}", name, meta.sha256))
|
||||
.collect();
|
||||
sha256_hex(parts.join("|").as_bytes())
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn detect_system_device_name() -> Option<String> {
|
||||
let env_name = [
|
||||
"CC_SWITCH_DEVICE_NAME",
|
||||
"COMPUTERNAME",
|
||||
"HOSTNAME",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var(key).ok())
|
||||
.find_map(|value| normalize_device_name(&value));
|
||||
|
||||
if env_name.is_some() {
|
||||
return env_name;
|
||||
}
|
||||
|
||||
let output = Command::new("hostname").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let hostname = String::from_utf8(output.stdout).ok()?;
|
||||
normalize_device_name(&hostname)
|
||||
}
|
||||
|
||||
fn normalize_device_name(raw: &str) -> Option<String> {
|
||||
let compact = raw.chars().fold(String::with_capacity(raw.len()), |mut acc, ch| {
|
||||
if ch.is_whitespace() {
|
||||
acc.push(' ');
|
||||
} else if !ch.is_control() {
|
||||
acc.push(ch);
|
||||
}
|
||||
acc
|
||||
});
|
||||
let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = normalized.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let limited = trimmed.chars().take(MAX_DEVICE_NAME_LEN).collect::<String>();
|
||||
if limited.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(limited)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> {
|
||||
if manifest.format != PROTOCOL_FORMAT {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_format_incompatible",
|
||||
format!("远端 manifest 格式不兼容: {}", manifest.format),
|
||||
format!(
|
||||
"Remote manifest format is incompatible: {}",
|
||||
manifest.format
|
||||
),
|
||||
));
|
||||
}
|
||||
if manifest.version != PROTOCOL_VERSION {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_version_incompatible",
|
||||
format!(
|
||||
"远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})",
|
||||
manifest.version
|
||||
),
|
||||
format!(
|
||||
"Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})",
|
||||
manifest.version
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Download & verify ───────────────────────────────────────
|
||||
|
||||
async fn download_and_verify(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
artifact_name: &str,
|
||||
artifacts: &BTreeMap<String, ArtifactMeta>,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let meta = artifacts.get(artifact_name).ok_or_else(|| {
|
||||
localized(
|
||||
"webdav.sync.manifest_missing_artifact",
|
||||
format!("manifest 中缺少 artifact: {artifact_name}"),
|
||||
format!("Manifest missing artifact: {artifact_name}"),
|
||||
)
|
||||
})?;
|
||||
let url = remote_file_url(settings, artifact_name)?;
|
||||
let (bytes, _) = get_bytes(&url, auth).await?.ok_or_else(|| {
|
||||
localized(
|
||||
"webdav.sync.remote_missing_artifact",
|
||||
format!("远端缺少 artifact 文件: {artifact_name}"),
|
||||
format!("Remote artifact file missing: {artifact_name}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Quick size check before expensive hash
|
||||
if bytes.len() as u64 != meta.size {
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_size_mismatch",
|
||||
format!(
|
||||
"artifact {artifact_name} 大小不匹配 (expected: {}, got: {})",
|
||||
meta.size,
|
||||
bytes.len(),
|
||||
),
|
||||
format!(
|
||||
"Artifact {artifact_name} size mismatch (expected: {}, got: {})",
|
||||
meta.size,
|
||||
bytes.len(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let actual_hash = sha256_hex(&bytes);
|
||||
if actual_hash != meta.sha256 {
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_hash_mismatch",
|
||||
format!(
|
||||
"artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)",
|
||||
meta.sha256.get(..8).unwrap_or(&meta.sha256),
|
||||
actual_hash.get(..8).unwrap_or(&actual_hash),
|
||||
),
|
||||
format!(
|
||||
"Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)",
|
||||
meta.sha256.get(..8).unwrap_or(&meta.sha256),
|
||||
actual_hash.get(..8).unwrap_or(&actual_hash),
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn apply_snapshot(
|
||||
db: &crate::database::Database,
|
||||
db_sql: &[u8],
|
||||
skills_zip: &[u8],
|
||||
) -> Result<(), AppError> {
|
||||
let sql_str = std::str::from_utf8(db_sql).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.sql_not_utf8",
|
||||
format!("SQL 非 UTF-8: {e}"),
|
||||
format!("SQL is not valid UTF-8: {e}"),
|
||||
)
|
||||
})?;
|
||||
let skills_backup = backup_current_skills()?;
|
||||
|
||||
// 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。
|
||||
restore_skills_zip(skills_zip)?;
|
||||
|
||||
if let Err(db_err) = db.import_sql_string(sql_str) {
|
||||
if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {
|
||||
return Err(localized(
|
||||
"webdav.sync.db_import_and_rollback_failed",
|
||||
format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"),
|
||||
format!(
|
||||
"Database import failed: {db_err}; skills rollback also failed: {rollback_err}"
|
||||
),
|
||||
));
|
||||
}
|
||||
return Err(db_err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Remote path helpers ─────────────────────────────────────
|
||||
|
||||
fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec<String> {
|
||||
let mut segs = Vec::new();
|
||||
segs.extend(path_segments(&settings.remote_root).map(str::to_string));
|
||||
segs.push(format!("v{PROTOCOL_VERSION}"));
|
||||
segs.extend(path_segments(&settings.profile).map(str::to_string));
|
||||
segs
|
||||
}
|
||||
|
||||
fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result<String, AppError> {
|
||||
let mut segs = remote_dir_segments(settings);
|
||||
segs.extend(path_segments(file_name).map(str::to_string));
|
||||
build_remote_url(&settings.base_url, &segs)
|
||||
}
|
||||
|
||||
fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
|
||||
auth_from_credentials(&settings.username, &settings.password)
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn artifact(sha256: &str, size: u64) -> ArtifactMeta {
|
||||
ArtifactMeta {
|
||||
sha256: sha256.to_string(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_is_stable() {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc123", 100));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def456", 200));
|
||||
|
||||
let id1 = compute_snapshot_id(&artifacts);
|
||||
let id2 = compute_snapshot_id(&artifacts);
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_changes_with_artifacts() {
|
||||
let mut a1 = BTreeMap::new();
|
||||
a1.insert("db.sql".to_string(), artifact("hash-a", 1));
|
||||
|
||||
let mut a2 = BTreeMap::new();
|
||||
a2.insert("db.sql".to_string(), artifact("hash-b", 1));
|
||||
|
||||
assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dir_segments_uses_v2() {
|
||||
let settings = WebDavSyncSettings {
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
let segs = remote_dir_segments(&settings);
|
||||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_is_correct() {
|
||||
let hash = sha256_hex(b"hello");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_best_effort_returns_true_on_success() {
|
||||
let mut settings = WebDavSyncSettings::default();
|
||||
let ok = persist_sync_success_best_effort(
|
||||
&mut settings,
|
||||
"hash".to_string(),
|
||||
Some("etag".to_string()),
|
||||
|_settings, _hash, _etag| Ok(()),
|
||||
);
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_best_effort_returns_false_on_error() {
|
||||
let mut settings = WebDavSyncSettings::default();
|
||||
let ok = persist_sync_success_best_effort(
|
||||
&mut settings,
|
||||
"hash".to_string(),
|
||||
None,
|
||||
|_settings, _hash, _etag| Err(AppError::Config("boom".to_string())),
|
||||
);
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
fn manifest_with(format: &str, version: u32) -> SyncManifest {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc", 1));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def", 2));
|
||||
SyncManifest {
|
||||
format: format.to_string(),
|
||||
version,
|
||||
device_name: "My MacBook".to_string(),
|
||||
created_at: "2026-02-12T00:00:00Z".to_string(),
|
||||
artifacts,
|
||||
snapshot_id: "snap-1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_supported_manifest() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION);
|
||||
assert!(validate_manifest_compat(&manifest).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_format() {
|
||||
let manifest = manifest_with("other-format", PROTOCOL_VERSION);
|
||||
assert!(validate_manifest_compat(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_version() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1);
|
||||
assert!(validate_manifest_compat(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_returns_none_for_blank_input() {
|
||||
assert_eq!(normalize_device_name(" \n\t "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_collapses_whitespace_and_drops_control_chars() {
|
||||
assert_eq!(
|
||||
normalize_device_name(" Mac\tBook \n Pro\u{0007} "),
|
||||
Some("Mac Book Pro".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_truncates_to_max_len() {
|
||||
let long = "a".repeat(80);
|
||||
assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_serialization_uses_device_name_only() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION);
|
||||
let value = serde_json::to_value(&manifest).expect("serialize manifest");
|
||||
assert!(
|
||||
value.get("deviceName").is_some(),
|
||||
"manifest should contain deviceName"
|
||||
);
|
||||
assert!(
|
||||
value.get("deviceId").is_none(),
|
||||
"manifest should not contain deviceId"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tempfile::{tempdir, TempDir};
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::DateTime;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::skill::SkillService;
|
||||
|
||||
use super::{io_context_localized, localized, REMOTE_SKILLS_ZIP};
|
||||
|
||||
/// Maximum total bytes allowed during zip extraction (512 MB).
|
||||
const MAX_EXTRACT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// Maximum number of entries allowed in a zip archive.
|
||||
const MAX_EXTRACT_ENTRIES: usize = 10_000;
|
||||
|
||||
pub(super) struct SkillsBackup {
|
||||
_tmp: TempDir,
|
||||
backup_dir: PathBuf,
|
||||
ssot_path: PathBuf,
|
||||
existed: bool,
|
||||
}
|
||||
|
||||
pub(super) fn zip_skills_ssot(dest_path: &Path) -> Result<(), AppError> {
|
||||
let source = SkillService::get_ssot_dir().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_ssot_dir_failed",
|
||||
format!("获取 Skills SSOT 目录失败: {e}"),
|
||||
format!("Failed to resolve Skills SSOT directory: {e}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let file = fs::File::create(dest_path).map_err(|e| AppError::io(dest_path, e))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.last_modified_time(DateTime::default());
|
||||
|
||||
if source.exists() {
|
||||
let canonical_root = fs::canonicalize(&source).unwrap_or_else(|_| source.clone());
|
||||
let mut visited = HashSet::new();
|
||||
mark_visited_dir(&canonical_root, &mut visited)?;
|
||||
zip_dir_recursive(
|
||||
&canonical_root,
|
||||
&canonical_root,
|
||||
&mut writer,
|
||||
options,
|
||||
&mut visited,
|
||||
)?;
|
||||
}
|
||||
|
||||
writer.finish().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_zip_write_failed",
|
||||
format!("写入 skills.zip 失败: {e}"),
|
||||
format!("Failed to write skills.zip: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"webdav.sync.skills_extract_tmpdir_failed",
|
||||
"创建 skills 解压临时目录失败",
|
||||
"Failed to create temporary directory for skills extraction",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
let zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
|
||||
fs::write(&zip_path, raw).map_err(|e| AppError::io(&zip_path, e))?;
|
||||
|
||||
let file = fs::File::open(&zip_path).map_err(|e| AppError::io(&zip_path, e))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_zip_parse_failed",
|
||||
format!("解析 skills.zip 失败: {e}"),
|
||||
format!("Failed to parse skills.zip: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let extracted = tmp.path().join("skills-extracted");
|
||||
fs::create_dir_all(&extracted).map_err(|e| AppError::io(&extracted, e))?;
|
||||
|
||||
if archive.len() > MAX_EXTRACT_ENTRIES {
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_many_entries",
|
||||
format!("skills.zip 条目数过多({}),上限 {MAX_EXTRACT_ENTRIES}", archive.len()),
|
||||
format!("skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}", archive.len()),
|
||||
));
|
||||
}
|
||||
|
||||
let mut total_bytes: u64 = 0;
|
||||
for idx in 0..archive.len() {
|
||||
let mut entry = archive.by_index(idx).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_zip_entry_read_failed",
|
||||
format!("读取 ZIP 项失败: {e}"),
|
||||
format!("Failed to read ZIP entry: {e}"),
|
||||
)
|
||||
})?;
|
||||
let Some(safe_name) = entry.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
let out_path = extracted.join(safe_name);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&out_path).map_err(|e| AppError::io(&out_path, e))?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
let mut out = fs::File::create(&out_path).map_err(|e| AppError::io(&out_path, e))?;
|
||||
let written = std::io::copy(&mut entry, &mut out).map_err(|e| AppError::io(&out_path, e))?;
|
||||
total_bytes += written;
|
||||
if total_bytes > MAX_EXTRACT_BYTES {
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_large",
|
||||
format!("skills.zip 解压后体积超过上限({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024),
|
||||
format!("skills.zip extracted size exceeds limit ({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let ssot = SkillService::get_ssot_dir().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_ssot_dir_failed",
|
||||
format!("获取 Skills SSOT 目录失败: {e}"),
|
||||
format!("Failed to resolve Skills SSOT directory: {e}"),
|
||||
)
|
||||
})?;
|
||||
let bak = ssot.with_extension("bak");
|
||||
|
||||
if ssot.exists() {
|
||||
if bak.exists() {
|
||||
let _ = fs::remove_dir_all(&bak);
|
||||
}
|
||||
fs::rename(&ssot, &bak).map_err(|e| AppError::io(&ssot, e))?;
|
||||
}
|
||||
|
||||
if let Err(e) = copy_dir_recursive(&extracted, &ssot) {
|
||||
if bak.exists() {
|
||||
let _ = fs::remove_dir_all(&ssot);
|
||||
let _ = fs::rename(&bak, &ssot);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&bak);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn backup_current_skills() -> Result<SkillsBackup, AppError> {
|
||||
let ssot = SkillService::get_ssot_dir().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_ssot_dir_failed",
|
||||
format!("获取 Skills SSOT 目录失败: {e}"),
|
||||
format!("Failed to resolve Skills SSOT directory: {e}"),
|
||||
)
|
||||
})?;
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"webdav.sync.skills_backup_tmpdir_failed",
|
||||
"创建 skills 备份临时目录失败",
|
||||
"Failed to create temporary directory for skills backup",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
let backup_dir = tmp.path().join("skills-backup");
|
||||
|
||||
let existed = ssot.exists();
|
||||
if existed {
|
||||
copy_dir_recursive(&ssot, &backup_dir)?;
|
||||
}
|
||||
|
||||
Ok(SkillsBackup {
|
||||
_tmp: tmp,
|
||||
backup_dir,
|
||||
ssot_path: ssot,
|
||||
existed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn restore_skills_from_backup(backup: &SkillsBackup) -> Result<(), AppError> {
|
||||
if backup.ssot_path.exists() {
|
||||
fs::remove_dir_all(&backup.ssot_path).map_err(|e| AppError::io(&backup.ssot_path, e))?;
|
||||
}
|
||||
|
||||
if backup.existed {
|
||||
copy_dir_recursive(&backup.backup_dir, &backup.ssot_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn zip_dir_recursive(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
writer: &mut zip::ZipWriter<fs::File>,
|
||||
options: SimpleFileOptions,
|
||||
visited: &mut HashSet<PathBuf>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut entries: Vec<_> = fs::read_dir(current)
|
||||
.map_err(|e| AppError::io(current, e))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| AppError::io(current, e))?;
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
if name_str.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let real_path = match fs::canonicalize(&path) {
|
||||
Ok(p) if p.starts_with(root) => p,
|
||||
Ok(_) => {
|
||||
log::warn!(
|
||||
"[WebDAV] Skipping symlink outside skills root: {}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(_) => path.clone(),
|
||||
};
|
||||
|
||||
let rel = real_path
|
||||
.strip_prefix(root)
|
||||
.or_else(|_| path.strip_prefix(root))
|
||||
.map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.zip_relative_path_failed",
|
||||
format!("生成 ZIP 相对路径失败: {e}"),
|
||||
format!("Failed to build relative ZIP path: {e}"),
|
||||
)
|
||||
})?;
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
|
||||
if real_path.is_dir() {
|
||||
if !mark_visited_dir(&real_path, visited)? {
|
||||
log::warn!(
|
||||
"[WebDAV] Skipping already visited directory: {}",
|
||||
real_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
writer
|
||||
.add_directory(format!("{rel_str}/"), options)
|
||||
.map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.zip_add_directory_failed",
|
||||
format!("写入 ZIP 目录失败: {e}"),
|
||||
format!("Failed to write ZIP directory entry: {e}"),
|
||||
)
|
||||
})?;
|
||||
zip_dir_recursive(root, &real_path, writer, options, visited)?;
|
||||
} else {
|
||||
writer.start_file(&rel_str, options).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.zip_start_file_failed",
|
||||
format!("写入 ZIP 文件头失败: {e}"),
|
||||
format!("Failed to start ZIP file entry: {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut file = fs::File::open(&real_path).map_err(|e| AppError::io(&real_path, e))?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| AppError::io(&real_path, e))?;
|
||||
writer.write_all(&buf).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.zip_write_file_failed",
|
||||
format!("写入 ZIP 文件内容失败: {e}"),
|
||||
format!("Failed to write ZIP file content: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), AppError> {
|
||||
let mut visited = HashSet::new();
|
||||
copy_dir_recursive_inner(src, dest, &mut visited)
|
||||
}
|
||||
|
||||
fn copy_dir_recursive_inner(
|
||||
src: &Path,
|
||||
dest: &Path,
|
||||
visited: &mut HashSet<PathBuf>,
|
||||
) -> Result<(), AppError> {
|
||||
if !src.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if !mark_visited_dir(src, visited)? {
|
||||
log::warn!(
|
||||
"[WebDAV] Skipping already visited copy path: {}",
|
||||
src.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
fs::create_dir_all(dest).map_err(|e| AppError::io(dest, e))?;
|
||||
for entry in fs::read_dir(src).map_err(|e| AppError::io(src, e))? {
|
||||
let entry = entry.map_err(|e| AppError::io(src, e))?;
|
||||
let path = entry.path();
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
if path.is_dir() {
|
||||
copy_dir_recursive_inner(&path, &dest_path, visited)?;
|
||||
} else {
|
||||
fs::copy(&path, &dest_path).map_err(|e| AppError::io(&dest_path, e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_visited_dir(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<bool, AppError> {
|
||||
let canonical = fs::canonicalize(path).map_err(|e| AppError::io(path, e))?;
|
||||
Ok(visited.insert(canonical))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mark_visited_dir;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn mark_visited_dir_tracks_canonical_duplicates() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let dir = temp.path().join("skills");
|
||||
std::fs::create_dir_all(&dir).expect("create dir");
|
||||
|
||||
let mut visited = HashSet::new();
|
||||
assert!(mark_visited_dir(&dir, &mut visited).expect("first visit"));
|
||||
assert!(!mark_visited_dir(&dir, &mut visited).expect("second visit"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user