diff --git a/scripts/benchmark_cc_switch.py b/scripts/benchmark_cc_switch.py index e585061e..3cc97e4c 100755 --- a/scripts/benchmark_cc_switch.py +++ b/scripts/benchmark_cc_switch.py @@ -58,6 +58,8 @@ "open_providers", "provider_switch_a_to_b", } +PROVIDER_SWITCH_CONFLICT_INPUT = b"\x1b[B\n" * 32 +TUI_PROVIDER_SWITCH_CONFLICT_INPUT = b"c" * 32 class BenchmarkAbort(RuntimeError): @@ -1408,8 +1410,12 @@ def timed_cli( args: list[str], timeout: float = 60.0, fail_fast: bool = False, + runner: Callable[[list[str], float, bool], RunResult] | None = None, ) -> RunResult: - result = run_command([str(binary), *args], timeout=timeout, capture_timeout=fail_fast) + if runner is None: + result = run_command([str(binary), *args], timeout=timeout, capture_timeout=fail_fast) + else: + result = runner([str(binary), *args], timeout, fail_fast) if result.code == 0: metrics.add(surface, app, operation, result.ms) else: @@ -1420,8 +1426,12 @@ def timed_cli( return result +def run_provider_switch_cli(args: list[str], timeout: float = 60.0, _capture_timeout: bool = False) -> RunResult: + return run_pty_command(args, send=PROVIDER_SWITCH_CONFLICT_INPUT, timeout=timeout) + + def reset_provider_cli(binary: Path, app: str, provider_id: str) -> None: - result = run_command([str(binary), "--app", app, "provider", "switch", provider_id], timeout=60) + result = run_provider_switch_cli([str(binary), "--app", app, "provider", "switch", provider_id], timeout=60) if result.code != 0: raise RuntimeError(f"failed to reset {app} to {provider_id}: {result.stderr or result.stdout}") @@ -1477,7 +1487,16 @@ def benchmark_cli( if cli_enabled("provider_switch_a_to_b"): reset_provider_cli(binary, app, a) - switched = timed_cli(prefix_metrics, binary, "CLI", app, "provider_switch_a_to_b", ["--app", app, "provider", "switch", b], fail_fast=fail_fast and record) + switched = timed_cli( + prefix_metrics, + binary, + "CLI", + app, + "provider_switch_a_to_b", + ["--app", app, "provider", "switch", b], + fail_fast=fail_fast and record, + runner=run_provider_switch_cli, + ) if switched.code != 0: reset_provider_cli(binary, app, a) continue @@ -1823,6 +1842,8 @@ def switch_body(session: TuiSession) -> float: session.clear() start_switch = time.perf_counter() session.send(b" ") + time.sleep(0.15) + session.send(TUI_PROVIDER_SWITCH_CONFLICT_INPUT) wait_until_tui(session, lambda: effective_current_provider(paths, app) == b, timeout=8) return (time.perf_counter() - start_switch) * 1000 diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index aee6c0a8..7cfc70d6 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -866,8 +866,10 @@ mod tests { crate::test_support::set_test_home_override(Some(home)); crate::settings::reload_test_settings(); - let mut settings = crate::settings::AppSettings::default(); - settings.gemini_config_dir = Some(stale_gemini_dir.to_string_lossy().into_owned()); + let settings = crate::settings::AppSettings { + gemini_config_dir: Some(stale_gemini_dir.to_string_lossy().into_owned()), + ..Default::default() + }; settings.save().expect("save stale settings"); crate::settings::reload_test_settings(); diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs index 2c3c4f1b..886d07e3 100644 --- a/src-tauri/src/cli/claude_temp_launch.rs +++ b/src-tauri/src/cli/claude_temp_launch.rs @@ -307,7 +307,7 @@ mod tests { #[cfg(unix)] use std::process::Stdio; #[cfg(unix)] - use std::time::Duration; + use std::time::{Duration, Instant}; use tempfile::TempDir; #[cfg(unix)] @@ -322,6 +322,19 @@ mod tests { path } + #[cfg(unix)] + fn wait_until_exists(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + while !path.exists() { + assert!( + Instant::now() < deadline, + "timed out waiting for {}", + path.display() + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[cfg(unix)] #[test] fn unix_handoff_command_wraps_claude_and_cleans_up_temp_settings() { @@ -360,10 +373,14 @@ mod tests { #[test] fn interrupting_handoff_still_cleans_up_temp_settings() { let temp_dir = TempDir::new().expect("create temp dir"); + let ready_path = temp_dir.path().join("claude-ready"); let executable = write_test_executable( &temp_dir, "claude-stub.sh", - "trap 'exit 130' INT TERM HUP\nwhile :; do sleep 1; done", + &format!( + "trap 'exit 130' INT TERM HUP\nprintf '%s\\n' ready > {:?}\nwhile :; do sleep 1; done", + ready_path + ), ); let settings_path = temp_dir.path().join("cc-switch-claude-settings.json"); std::fs::write(&settings_path, "{}").expect("seed temp settings"); @@ -384,7 +401,7 @@ mod tests { } let mut child = command.spawn().expect("spawn handoff"); - std::thread::sleep(Duration::from_millis(150)); + wait_until_exists(&ready_path); let kill_result = unsafe { libc::kill(-(child.id() as i32), libc::SIGINT) }; assert_eq!(kill_result, 0, "send SIGINT to handoff process group"); diff --git a/src-tauri/src/cli/codex_temp_launch.rs b/src-tauri/src/cli/codex_temp_launch.rs index 73224d46..139e85d8 100644 --- a/src-tauri/src/cli/codex_temp_launch.rs +++ b/src-tauri/src/cli/codex_temp_launch.rs @@ -330,7 +330,7 @@ mod tests { #[cfg(unix)] use std::process::Stdio; #[cfg(unix)] - use std::time::Duration; + use std::time::{Duration, Instant}; use tempfile::TempDir; #[cfg(unix)] @@ -345,6 +345,19 @@ mod tests { path } + #[cfg(unix)] + fn wait_until_exists(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + while !path.exists() { + assert!( + Instant::now() < deadline, + "timed out waiting for {}", + path.display() + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + fn provider_with(config: &str, auth: Option) -> Provider { let mut settings = serde_json::Map::new(); settings.insert("config".to_string(), Value::String(config.to_string())); @@ -405,10 +418,14 @@ mod tests { #[test] fn interrupting_handoff_still_cleans_up_temp_codex_home() { let temp_dir = TempDir::new().expect("create temp dir"); + let ready_path = temp_dir.path().join("codex-ready"); let executable = write_test_executable( &temp_dir, "codex-stub.sh", - "trap 'exit 130' INT TERM HUP\nwhile :; do sleep 1; done", + &format!( + "trap 'exit 130' INT TERM HUP\nprintf '%s\\n' ready > {:?}\nwhile :; do sleep 1; done", + ready_path + ), ); let codex_home = temp_dir.path().join("cc-switch-codex-home"); std::fs::create_dir_all(&codex_home).expect("create temp codex home"); @@ -433,7 +450,7 @@ mod tests { } let mut child = command.spawn().expect("spawn handoff"); - std::thread::sleep(Duration::from_millis(150)); + wait_until_exists(&ready_path); let kill_result = unsafe { libc::kill(-(child.id() as i32), libc::SIGINT) }; assert_eq!(kill_result, 0, "send SIGINT to handoff process group"); diff --git a/src-tauri/src/cli/commands/config.rs b/src-tauri/src/cli/commands/config.rs index 36adb0fe..d077c47e 100644 --- a/src-tauri/src/cli/commands/config.rs +++ b/src-tauri/src/cli/commands/config.rs @@ -136,7 +136,7 @@ fn show_path() -> Result<(), AppError> { Ok(()) } -fn export_config(file: &PathBuf) -> Result<(), AppError> { +fn export_config(file: &Path) -> Result<(), AppError> { println!( "{}", info(&format!("Exporting configuration to {}...", file.display())) @@ -174,7 +174,7 @@ fn export_config(file: &PathBuf) -> Result<(), AppError> { Ok(()) } -fn import_config(file: &PathBuf) -> Result<(), AppError> { +fn import_config(file: &Path) -> Result<(), AppError> { println!( "{}", info(&format!( diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index 63290a8d..bfd2c9f4 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -1,12 +1,14 @@ use clap::Subcommand; +use std::cell::RefCell; use std::fs; use std::path::Path; +use super::live_conflict::PromptConflictResolver; use crate::app_config::AppType; use crate::cli::i18n::texts; use crate::cli::ui::{highlight, info, success}; use crate::error::AppError; -use crate::services::ProviderService; +use crate::services::{provider::live_merge, ProviderService}; use crate::store::AppState; #[derive(Subcommand, Debug, Clone)] @@ -114,6 +116,14 @@ fn get_state() -> Result { AppState::try_new() } +fn with_prompt_conflict_resolution( + f: impl FnOnce(live_merge::ConflictResolution<'_>) -> Result, +) -> Result { + let mut resolver = PromptConflictResolver; + let resolver = RefCell::new(&mut resolver as &mut dyn live_merge::ConflictResolver); + f(live_merge::ConflictResolution::Resolver(&resolver)) +} + fn no_current_provider_message(action: CommonConfigSnippetAction) -> &'static str { match action { CommonConfigSnippetAction::Set => texts::common_config_snippet_no_current_provider(), @@ -185,7 +195,7 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result { - let value: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { + let value: serde_json::Value = serde_json::from_str(raw).map_err(|e| { AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string())) })?; if !value.is_object() { @@ -237,7 +247,14 @@ fn set( let snippet = canonical_common_snippet(app_type.clone(), &raw)?.unwrap_or_default(); let state = get_state()?; - ProviderService::set_common_config_snippet(&state, app_type.clone(), Some(snippet))?; + with_prompt_conflict_resolution(|resolution| { + ProviderService::set_common_config_snippet_with_resolution( + &state, + app_type.clone(), + Some(snippet), + resolution, + ) + })?; println!( "{}", @@ -296,11 +313,14 @@ fn extract( if save { let snippet = canonical_common_snippet(app_type.clone(), &extracted)?.unwrap_or_default(); - ProviderService::set_common_config_snippet( - &state, - app_type.clone(), - Some(snippet.clone()), - )?; + with_prompt_conflict_resolution(|resolution| { + ProviderService::set_common_config_snippet_with_resolution( + &state, + app_type.clone(), + Some(snippet.clone()), + resolution, + ) + })?; println!("{}", success(texts::common_config_snippet_extracted())); if !snippet.trim().is_empty() { println!(); @@ -317,7 +337,14 @@ fn extract( fn clear(app_type: AppType, _apply: bool) -> Result<(), AppError> { let state = get_state()?; - ProviderService::clear_common_config_snippet(&state, app_type.clone())?; + with_prompt_conflict_resolution(|resolution| { + ProviderService::set_common_config_snippet_with_resolution( + &state, + app_type.clone(), + None, + resolution, + ) + })?; println!( "{}", @@ -389,11 +416,12 @@ mod tests { &get_claude_settings_path(), &json!({ "env": { - "ANTHROPIC_BASE_URL": "https://stale.example" + "ANTHROPIC_BASE_URL": "https://provider.example", + "LOCAL_ONLY": "preserve-me" } }), ) - .expect("seed stale live settings"); + .expect("seed live settings"); (temp_home, env) } @@ -678,9 +706,6 @@ mod tests { #[test] fn follow_up_message_is_omitted_for_additive_apps() { - assert!(matches!( - follow_up_message(AppType::OpenCode, CommonConfigSnippetAction::Set, ""), - None - )); + assert!(follow_up_message(AppType::OpenCode, CommonConfigSnippetAction::Set, "").is_none()); } } diff --git a/src-tauri/src/cli/commands/live_conflict.rs b/src-tauri/src/cli/commands/live_conflict.rs new file mode 100644 index 00000000..226e123f --- /dev/null +++ b/src-tauri/src/cli/commands/live_conflict.rs @@ -0,0 +1,41 @@ +use inquire::Select; + +use crate::cli::i18n::texts; +use crate::error::AppError; +use crate::services::provider::live_merge::{self, ConfigConflict}; + +pub(crate) struct PromptConflictResolver; + +impl live_merge::ConflictResolver for PromptConflictResolver { + fn resolve_conflict( + &mut self, + conflict: &ConfigConflict, + ) -> Result { + let keep_local = "Keep local value".to_string(); + let use_incoming = "Use cc-switch value".to_string(); + let prompt = format!( + "Live configuration conflict\nApplication: {}\nTarget: {}\nField: {}\nLocal value: {}\ncc-switch value: {}\nChoose value:", + conflict.app_type.as_str(), + conflict.target, + conflict.path, + conflict.local, + conflict.incoming, + ); + + let selected = Select::new(&prompt, vec![keep_local.clone(), use_incoming.clone()]) + .prompt() + .map_err(|err| match err { + inquire::error::InquireError::OperationCanceled + | inquire::error::InquireError::OperationInterrupted => { + AppError::Message(texts::selection_cancelled().to_string()) + } + other => AppError::Message(texts::input_failed_error(&other.to_string())), + })?; + + if selected == use_incoming { + Ok(live_merge::ConflictChoice::UseIncoming) + } else { + Ok(live_merge::ConflictChoice::KeepLocal) + } + } +} diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index dca920d1..29ee2621 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -11,6 +11,7 @@ pub mod env; pub mod failover; pub mod hermes; pub mod internal; +pub(crate) mod live_conflict; pub mod mcp; pub mod prompts; pub mod provider; diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index a3932a3c..49916ebf 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -1,7 +1,7 @@ use clap::{Subcommand, ValueEnum}; -use std::{collections::HashSet, path::PathBuf}; +use std::{cell::RefCell, collections::HashSet, path::PathBuf}; -use super::{provider_inspect, provider_usage_query}; +use super::{live_conflict::PromptConflictResolver, provider_inspect, provider_usage_query}; use crate::app_config::AppType; use crate::cli::commands::provider_input::{ build_provider_from_add_template, common_snippet_has_effective_config, current_timestamp, @@ -15,7 +15,7 @@ use crate::cli::i18n::texts; use crate::cli::ui::{highlight, info, success, warning}; use crate::error::AppError; use crate::provider::{AuthBinding, AuthBindingSource, ClaudeApiKeyField, Provider, ProviderMeta}; -use crate::services::{AuthService, ManagedAuthAccount, ProviderService}; +use crate::services::{provider::live_merge, AuthService, ManagedAuthAccount, ProviderService}; use crate::store::AppState; use inquire::{Confirm, Select}; @@ -683,6 +683,14 @@ fn get_state() -> Result { AppState::try_new() } +fn with_prompt_conflict_resolution( + f: impl FnOnce(live_merge::ConflictResolution<'_>) -> Result, +) -> Result { + let mut resolver = PromptConflictResolver; + let resolver = RefCell::new(&mut resolver as &mut dyn live_merge::ConflictResolver); + f(live_merge::ConflictResolution::Resolver(&resolver)) +} + fn switch_provider(app_type: AppType, id: &str) -> Result<(), AppError> { let state = get_state()?; let app_str = app_type.as_str().to_string(); @@ -695,7 +703,9 @@ fn switch_provider(app_type: AppType, id: &str) -> Result<(), AppError> { }; // 执行切换 - ProviderService::switch(&state, app_type.clone(), id)?; + with_prompt_conflict_resolution(|resolution| { + ProviderService::switch_with_resolution(&state, app_type.clone(), id, resolution) + })?; if let Err(err) = crate::claude_plugin::sync_claude_plugin_on_provider_switch(&app_type, &provider) { @@ -899,7 +909,9 @@ fn add_provider(app_type: AppType, template: Option) -> Res // 7. 调用 Service 层 let provider_id = provider.id.clone(); - ProviderService::add(&state, app_type.clone(), provider)?; + with_prompt_conflict_resolution(|resolution| { + ProviderService::add_with_resolution(&state, app_type.clone(), provider, resolution) + })?; // 8. 成功消息 println!( @@ -1017,7 +1029,9 @@ fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> { } // 8. 调用 Service 层 - ProviderService::update(&state, app_type.clone(), updated)?; + with_prompt_conflict_resolution(|resolution| { + ProviderService::update_with_resolution(&state, app_type.clone(), updated, resolution) + })?; // 9. 成功消息 println!( @@ -1031,103 +1045,439 @@ fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; +fn provider_copy_id(original_id: &str, existing_ids: &[String]) -> String { + let base_id = format!("{}-copy", original_id.trim()); + if !existing_ids.iter().any(|id| id == &base_id) { + return base_id; + } - fn claude_provider(settings_config: serde_json::Value) -> Provider { - Provider::with_id( - "provider-1".to_string(), - "Provider One".to_string(), - settings_config, - None, - ) + let mut counter = 2; + loop { + let candidate = format!("{base_id}-{counter}"); + if !existing_ids.iter().any(|id| id == &candidate) { + return candidate; + } + counter += 1; } +} - fn codex_provider(settings_config: serde_json::Value) -> Provider { - Provider::with_id( - "codex-provider".to_string(), - "Codex Provider".to_string(), - settings_config, - None, - ) +fn existing_provider_ids_for_duplicate( + app_type: &AppType, + manager_ids: impl IntoIterator, +) -> Result, AppError> { + let mut ids = manager_ids.into_iter().collect::>(); + if app_type.is_additive_mode() { + let live_ids = match app_type { + AppType::OpenCode => crate::opencode_config::get_providers()? + .into_iter() + .map(|(id, _)| id) + .collect::>(), + AppType::Hermes => crate::hermes_config::get_providers()? + .into_iter() + .map(|(id, _)| id) + .collect::>(), + AppType::OpenClaw => crate::openclaw_config::get_providers()? + .into_iter() + .map(|(id, _)| id) + .collect::>(), + _ => Vec::new(), + }; + ids.extend(live_ids); } + Ok(ids.into_iter().collect()) +} - #[test] - fn claude_api_format_effective_value_prefers_meta_over_legacy_settings() { - let mut provider = claude_provider(json!({ - "api_format": "openai_chat", - "openrouter_compat_mode": true - })); - provider.meta = Some(ProviderMeta { - api_format: Some("openai_responses".to_string()), - ..Default::default() - }); +fn provider_duplicate_draft(source: &Provider, existing_ids: &[String]) -> Provider { + let mut draft = source.clone(); + draft.id = provider_copy_id(&source.id, existing_ids); + draft.name = format!("{} copy", source.name.trim()); + draft.created_at = None; + draft.in_failover_queue = false; + draft +} - assert_eq!( - effective_claude_api_format(&provider), - CLAUDE_API_FORMAT_OPENAI_RESPONSES - ); +fn duplicate_provider(app_type: AppType, id: &str, edit: bool) -> Result<(), AppError> { + if edit { + return duplicate_provider_interactive(app_type, id); } - #[test] - fn claude_api_format_effective_value_preserves_gemini_native_meta() { - let mut provider = claude_provider(json!({ - "api_format": "openai_chat" - })); - provider.meta = Some(ProviderMeta { - api_format: Some(CLAUDE_API_FORMAT_GEMINI_NATIVE.to_string()), - ..Default::default() - }); + let state = AppState::try_new()?; + let duplicate = ProviderService::duplicate(&state, app_type, id, None)?; - assert_eq!( - effective_claude_api_format(&provider), - CLAUDE_API_FORMAT_GEMINI_NATIVE - ); - } + println!( + "{}", + success(&texts::provider_duplicated_success(id, &duplicate.id)) + ); + Ok(()) +} - #[test] - fn claude_api_format_effective_value_reads_legacy_openrouter_flag() { - let provider = claude_provider(json!({ - "openrouter_compat_mode": "true" - })); +fn duplicate_provider_interactive(app_type: AppType, id: &str) -> Result<(), AppError> { + crate::cli::terminal::disable_bracketed_paste_mode_best_effort(); - assert_eq!( - effective_claude_api_format(&provider), - CLAUDE_API_FORMAT_OPENAI_CHAT - ); + println!("{}", highlight(&format!("Duplicate Provider: {}", id))); + println!("{}", "=".repeat(50)); + + let state = AppState::try_new()?; + let config = state.config.read().unwrap(); + let manager = config + .get_manager(&app_type) + .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?; + let source = manager + .providers + .get(id) + .ok_or_else(|| { + let msg = texts::entity_not_found(texts::entity_provider(), id); + AppError::localized("provider.not_found", msg.clone(), msg) + })? + .clone(); + let existing_ids = + existing_provider_ids_for_duplicate(&app_type, manager.providers.keys().cloned())?; + let common_snippet = config.common_config_snippets.get(&app_type).cloned(); + drop(config); + + let draft = provider_duplicate_draft(&source, &existing_ids); + + println!("\n{}", highlight(texts::current_config_header())); + display_provider_summary(&draft, &app_type); + println!(); + println!("{}", info(texts::edit_fields_instruction())); + + let (name, website_url) = prompt_basic_fields(Some(&draft))?; + let settings_prompt_result = if Confirm::new(texts::modify_provider_config_prompt()) + .with_default(false) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))? + { + Some(prompt_settings_config( + &app_type, + Some(&draft.settings_config), + draft.meta.as_ref(), + matches!(app_type, AppType::Codex) && source.is_codex_official(), + )?) + } else { + None + }; + let settings_config = settings_prompt_result + .as_ref() + .map(|result| result.settings_config.clone()) + .unwrap_or_else(|| draft.settings_config.clone()); + + let optional = if Confirm::new(texts::modify_optional_fields_prompt()) + .with_default(false) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))? + { + prompt_optional_fields(Some(&draft))? + } else { + OptionalFields::from_provider(&draft) + }; + + let mut copied = Provider { + id: draft.id.clone(), + name: name.trim().to_string(), + settings_config, + website_url, + category: source.category.clone(), + created_at: None, + sort_index: optional.sort_index, + notes: optional.notes, + icon: source.icon.clone(), + icon_color: source.icon_color.clone(), + meta: source.meta.clone(), + in_failover_queue: false, + }; + apply_settings_prompt_result_metadata(&app_type, &mut copied, settings_prompt_result.as_ref()); + prompt_and_apply_provider_api_format(&app_type, &mut copied)?; + prompt_and_apply_codex_oauth_provider_options(&app_type, &mut copied)?; + if let Some(enabled) = + prompt_common_config_enabled(&app_type, common_snippet.as_deref(), Some(&copied))? + { + set_provider_common_config_meta(&mut copied, enabled); } - #[test] - fn claude_api_format_apply_writes_canonical_meta_and_removes_legacy_settings() { - let mut provider = claude_provider(json!({ - "api_format": "anthropic", - "apiFormat": "openai_chat", - "openrouter_compat_mode": true, - "env": { - "ANTHROPIC_BASE_URL": "https://example.com" - } - })); + println!("\n{}", highlight(texts::updated_config_header())); + display_provider_summary(&copied, &app_type); + if !Confirm::new(&texts::confirm_create_entity(texts::entity_provider())) + .with_default(false) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))? + { + println!("{}", info(texts::cancelled())); + return Ok(()); + } - apply_claude_api_format(&mut provider, CLAUDE_API_FORMAT_OPENAI_CHAT); + let duplicate = ProviderService::duplicate(&state, app_type, id, Some(copied))?; + println!( + "{}", + success(&texts::provider_duplicated_success(id, &duplicate.id)) + ); + Ok(()) +} - assert_eq!( - provider - .meta - .as_ref() - .and_then(|meta| meta.api_format.as_deref()), - Some(CLAUDE_API_FORMAT_OPENAI_CHAT) +fn import_live_config(app_type: AppType) -> Result<(), AppError> { + let state = get_state()?; + let imported = ProviderService::import_live_config(&state, app_type.clone())?; + if imported > 0 { + println!( + "{}", + success(&format!( + "✓ Imported {imported} provider(s) from {} live config", + app_type.as_str() + )) ); - assert!(provider.settings_config.get("api_format").is_none()); - assert!(provider.settings_config.get("apiFormat").is_none()); - assert!(provider - .settings_config - .get("openrouter_compat_mode") - .is_none()); - assert_eq!( - provider.settings_config["env"]["ANTHROPIC_BASE_URL"], - "https://example.com" + } else { + println!( + "{}", + info(&format!( + "No providers imported from {} live config.", + app_type.as_str() + )) + ); + } + Ok(()) +} + +fn remove_from_config(app_type: AppType, id: &str) -> Result<(), AppError> { + let state = get_state()?; + ProviderService::remove_from_live_config(&state, app_type.clone(), id)?; + println!( + "{}", + success(&format!( + "✓ Removed provider '{}' from {} live config", + id, + app_type.as_str() + )) + ); + Ok(()) +} + +fn set_default_provider(app_type: AppType, id: &str, model: Option<&str>) -> Result<(), AppError> { + let state = get_state()?; + let default = ProviderService::set_default_model(&state, app_type.clone(), id, model)?; + println!( + "{}", + success(&format!( + "✓ Set '{}' as default for {}", + default, + app_type.as_str() + )) + ); + Ok(()) +} + +fn export_provider(app_type: AppType, id: &str, output: Option) -> Result<(), AppError> { + if !matches!(app_type, AppType::Claude) { + return Err(AppError::Message(format!( + "Provider export currently supports only Claude standalone settings files. Use --app claude (current app: {}).", + app_type.as_str() + ))); + } + + let state = get_state()?; + + // Single lock scope: get provider AND common_config_snippet together + let (provider, common_config_snippet) = { + let config = state.config.read().map_err(AppError::from)?; + let manager = config + .get_manager(&app_type) + .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?; + + let provider = manager + .providers + .get(id) + .ok_or_else(|| { + let msg = texts::provider_not_found(id); + AppError::localized("provider.not_found", msg.clone(), msg) + })? + .clone(); + + ( + provider, + config.common_config_snippets.get(&app_type).cloned(), + ) + }; + + let apply_common_config = ProviderService::provider_uses_common_config_for_app( + &app_type, + &provider, + common_config_snippet.as_deref(), + ); + + let output_path = match output { + None => { + // Default: {cwd}/.claude/settings.local.json (auto-loaded by Claude CLI) + std::env::current_dir() + .map_err(|e| AppError::Message(format!("无法获取当前工作目录: {}", e)))? + .join(".claude") + .join("settings.local.json") + } + Some(path) => { + // If path looks like a directory (no .json extension), append settings-{name}.json + let path_str = path.to_string_lossy(); + if path_str.ends_with('/') || path_str.ends_with('\\') || !path_str.ends_with(".json") { + path.join(format!( + "settings-{}.json", + crate::config::sanitize_provider_name(&provider.name) + )) + } else { + path + } + } + }; + + if output_path.exists() { + let confirm = Confirm::new(&format!( + "File '{}' already exists. Overwrite?", + output_path.display() + )) + .with_default(false) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + + if !confirm { + println!("{}", info(texts::cancelled())); + return Ok(()); + } + } + + let settings_content = ProviderService::build_live_backup_snapshot( + &app_type, + &provider, + common_config_snippet.as_deref(), + apply_common_config, + )?; + + crate::config::write_json_file(&output_path, &settings_content)?; + + println!( + "{}", + success(&format!( + "✓ Exported provider '{}' to {}", + id, + output_path.display() + )) + ); + + // If output is settings.local.json, Claude CLI will auto-load it + if output_path + .file_name() + .map(|n| n.to_string_lossy() == "settings.local.json") + .unwrap_or(false) + { + println!( + "{}", + info("Claude CLI will auto-load this config. Just run: claude") + ); + } else { + println!( + "{}", + info(&format!( + "Use it with: claude --settings {}", + output_path.display() + )) + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn claude_provider(settings_config: serde_json::Value) -> Provider { + Provider::with_id( + "provider-1".to_string(), + "Provider One".to_string(), + settings_config, + None, + ) + } + + fn codex_provider(settings_config: serde_json::Value) -> Provider { + Provider::with_id( + "codex-provider".to_string(), + "Codex Provider".to_string(), + settings_config, + None, + ) + } + + #[test] + fn claude_api_format_effective_value_prefers_meta_over_legacy_settings() { + let mut provider = claude_provider(json!({ + "api_format": "openai_chat", + "openrouter_compat_mode": true + })); + provider.meta = Some(ProviderMeta { + api_format: Some("openai_responses".to_string()), + ..Default::default() + }); + + assert_eq!( + effective_claude_api_format(&provider), + CLAUDE_API_FORMAT_OPENAI_RESPONSES + ); + } + + #[test] + fn claude_api_format_effective_value_preserves_gemini_native_meta() { + let mut provider = claude_provider(json!({ + "api_format": "openai_chat" + })); + provider.meta = Some(ProviderMeta { + api_format: Some(CLAUDE_API_FORMAT_GEMINI_NATIVE.to_string()), + ..Default::default() + }); + + assert_eq!( + effective_claude_api_format(&provider), + CLAUDE_API_FORMAT_GEMINI_NATIVE + ); + } + + #[test] + fn claude_api_format_effective_value_reads_legacy_openrouter_flag() { + let provider = claude_provider(json!({ + "openrouter_compat_mode": "true" + })); + + assert_eq!( + effective_claude_api_format(&provider), + CLAUDE_API_FORMAT_OPENAI_CHAT + ); + } + + #[test] + fn claude_api_format_apply_writes_canonical_meta_and_removes_legacy_settings() { + let mut provider = claude_provider(json!({ + "api_format": "anthropic", + "apiFormat": "openai_chat", + "openrouter_compat_mode": true, + "env": { + "ANTHROPIC_BASE_URL": "https://example.com" + } + })); + + apply_claude_api_format(&mut provider, CLAUDE_API_FORMAT_OPENAI_CHAT); + + assert_eq!( + provider + .meta + .as_ref() + .and_then(|meta| meta.api_format.as_deref()), + Some(CLAUDE_API_FORMAT_OPENAI_CHAT) + ); + assert!(provider.settings_config.get("api_format").is_none()); + assert!(provider.settings_config.get("apiFormat").is_none()); + assert!(provider + .settings_config + .get("openrouter_compat_mode") + .is_none()); + assert_eq!( + provider.settings_config["env"]["ANTHROPIC_BASE_URL"], + "https://example.com" ); } @@ -1511,339 +1861,3 @@ wire_api = "chat" ); } } - -fn provider_copy_id(original_id: &str, existing_ids: &[String]) -> String { - let base_id = format!("{}-copy", original_id.trim()); - if !existing_ids.iter().any(|id| id == &base_id) { - return base_id; - } - - let mut counter = 2; - loop { - let candidate = format!("{base_id}-{counter}"); - if !existing_ids.iter().any(|id| id == &candidate) { - return candidate; - } - counter += 1; - } -} - -fn existing_provider_ids_for_duplicate( - app_type: &AppType, - manager_ids: impl IntoIterator, -) -> Result, AppError> { - let mut ids = manager_ids.into_iter().collect::>(); - if app_type.is_additive_mode() { - let live_ids = match app_type { - AppType::OpenCode => crate::opencode_config::get_providers()? - .into_iter() - .map(|(id, _)| id) - .collect::>(), - AppType::Hermes => crate::hermes_config::get_providers()? - .into_iter() - .map(|(id, _)| id) - .collect::>(), - AppType::OpenClaw => crate::openclaw_config::get_providers()? - .into_iter() - .map(|(id, _)| id) - .collect::>(), - _ => Vec::new(), - }; - ids.extend(live_ids); - } - Ok(ids.into_iter().collect()) -} - -fn provider_duplicate_draft(source: &Provider, existing_ids: &[String]) -> Provider { - let mut draft = source.clone(); - draft.id = provider_copy_id(&source.id, existing_ids); - draft.name = format!("{} copy", source.name.trim()); - draft.created_at = None; - draft.in_failover_queue = false; - draft -} - -fn duplicate_provider(app_type: AppType, id: &str, edit: bool) -> Result<(), AppError> { - if edit { - return duplicate_provider_interactive(app_type, id); - } - - let state = AppState::try_new()?; - let duplicate = ProviderService::duplicate(&state, app_type, id, None)?; - - println!( - "{}", - success(&texts::provider_duplicated_success(id, &duplicate.id)) - ); - Ok(()) -} - -fn duplicate_provider_interactive(app_type: AppType, id: &str) -> Result<(), AppError> { - crate::cli::terminal::disable_bracketed_paste_mode_best_effort(); - - println!("{}", highlight(&format!("Duplicate Provider: {}", id))); - println!("{}", "=".repeat(50)); - - let state = AppState::try_new()?; - let config = state.config.read().unwrap(); - let manager = config - .get_manager(&app_type) - .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?; - let source = manager - .providers - .get(id) - .ok_or_else(|| { - let msg = texts::entity_not_found(texts::entity_provider(), id); - AppError::localized("provider.not_found", msg.clone(), msg) - })? - .clone(); - let existing_ids = - existing_provider_ids_for_duplicate(&app_type, manager.providers.keys().cloned())?; - let common_snippet = config.common_config_snippets.get(&app_type).cloned(); - drop(config); - - let draft = provider_duplicate_draft(&source, &existing_ids); - - println!("\n{}", highlight(texts::current_config_header())); - display_provider_summary(&draft, &app_type); - println!(); - println!("{}", info(texts::edit_fields_instruction())); - - let (name, website_url) = prompt_basic_fields(Some(&draft))?; - let settings_prompt_result = if Confirm::new(texts::modify_provider_config_prompt()) - .with_default(false) - .prompt() - .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))? - { - Some(prompt_settings_config( - &app_type, - Some(&draft.settings_config), - draft.meta.as_ref(), - matches!(app_type, AppType::Codex) && source.is_codex_official(), - )?) - } else { - None - }; - let settings_config = settings_prompt_result - .as_ref() - .map(|result| result.settings_config.clone()) - .unwrap_or_else(|| draft.settings_config.clone()); - - let optional = if Confirm::new(texts::modify_optional_fields_prompt()) - .with_default(false) - .prompt() - .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))? - { - prompt_optional_fields(Some(&draft))? - } else { - OptionalFields::from_provider(&draft) - }; - - let mut copied = Provider { - id: draft.id.clone(), - name: name.trim().to_string(), - settings_config, - website_url, - category: source.category.clone(), - created_at: None, - sort_index: optional.sort_index, - notes: optional.notes, - icon: source.icon.clone(), - icon_color: source.icon_color.clone(), - meta: source.meta.clone(), - in_failover_queue: false, - }; - apply_settings_prompt_result_metadata(&app_type, &mut copied, settings_prompt_result.as_ref()); - prompt_and_apply_provider_api_format(&app_type, &mut copied)?; - prompt_and_apply_codex_oauth_provider_options(&app_type, &mut copied)?; - if let Some(enabled) = - prompt_common_config_enabled(&app_type, common_snippet.as_deref(), Some(&copied))? - { - set_provider_common_config_meta(&mut copied, enabled); - } - - println!("\n{}", highlight(texts::updated_config_header())); - display_provider_summary(&copied, &app_type); - if !Confirm::new(&texts::confirm_create_entity(texts::entity_provider())) - .with_default(false) - .prompt() - .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))? - { - println!("{}", info(texts::cancelled())); - return Ok(()); - } - - let duplicate = ProviderService::duplicate(&state, app_type, id, Some(copied))?; - println!( - "{}", - success(&texts::provider_duplicated_success(id, &duplicate.id)) - ); - Ok(()) -} - -fn import_live_config(app_type: AppType) -> Result<(), AppError> { - let state = get_state()?; - let imported = ProviderService::import_live_config(&state, app_type.clone())?; - if imported > 0 { - println!( - "{}", - success(&format!( - "✓ Imported {imported} provider(s) from {} live config", - app_type.as_str() - )) - ); - } else { - println!( - "{}", - info(&format!( - "No providers imported from {} live config.", - app_type.as_str() - )) - ); - } - Ok(()) -} - -fn remove_from_config(app_type: AppType, id: &str) -> Result<(), AppError> { - let state = get_state()?; - ProviderService::remove_from_live_config(&state, app_type.clone(), id)?; - println!( - "{}", - success(&format!( - "✓ Removed provider '{}' from {} live config", - id, - app_type.as_str() - )) - ); - Ok(()) -} - -fn set_default_provider(app_type: AppType, id: &str, model: Option<&str>) -> Result<(), AppError> { - let state = get_state()?; - let default = ProviderService::set_default_model(&state, app_type.clone(), id, model)?; - println!( - "{}", - success(&format!( - "✓ Set '{}' as default for {}", - default, - app_type.as_str() - )) - ); - Ok(()) -} - -fn export_provider(app_type: AppType, id: &str, output: Option) -> Result<(), AppError> { - if !matches!(app_type, AppType::Claude) { - return Err(AppError::Message(format!( - "Provider export currently supports only Claude standalone settings files. Use --app claude (current app: {}).", - app_type.as_str() - ))); - } - - let state = get_state()?; - - // Single lock scope: get provider AND common_config_snippet together - let (provider, common_config_snippet) = { - let config = state.config.read().map_err(AppError::from)?; - let manager = config - .get_manager(&app_type) - .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?; - - let provider = manager - .providers - .get(id) - .ok_or_else(|| { - let msg = texts::provider_not_found(id); - AppError::localized("provider.not_found", msg.clone(), msg) - })? - .clone(); - - ( - provider, - config.common_config_snippets.get(&app_type).cloned(), - ) - }; - - let apply_common_config = ProviderService::provider_uses_common_config_for_app( - &app_type, - &provider, - common_config_snippet.as_deref(), - ); - - let output_path = match output { - None => { - // Default: {cwd}/.claude/settings.local.json (auto-loaded by Claude CLI) - std::env::current_dir() - .map_err(|e| AppError::Message(format!("无法获取当前工作目录: {}", e)))? - .join(".claude") - .join("settings.local.json") - } - Some(path) => { - // If path looks like a directory (no .json extension), append settings-{name}.json - let path_str = path.to_string_lossy(); - if path_str.ends_with('/') || path_str.ends_with('\\') || !path_str.ends_with(".json") { - path.join(format!( - "settings-{}.json", - crate::config::sanitize_provider_name(&provider.name) - )) - } else { - path - } - } - }; - - if output_path.exists() { - let confirm = Confirm::new(&format!( - "File '{}' already exists. Overwrite?", - output_path.display() - )) - .with_default(false) - .prompt() - .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; - - if !confirm { - println!("{}", info(texts::cancelled())); - return Ok(()); - } - } - - let settings_content = ProviderService::build_live_backup_snapshot( - &app_type, - &provider, - common_config_snippet.as_deref(), - apply_common_config, - )?; - - crate::config::write_json_file(&output_path, &settings_content)?; - - println!( - "{}", - success(&format!( - "✓ Exported provider '{}' to {}", - id, - output_path.display() - )) - ); - - // If output is settings.local.json, Claude CLI will auto-load it - if output_path - .file_name() - .map(|n| n.to_string_lossy() == "settings.local.json") - .unwrap_or(false) - { - println!( - "{}", - info("Claude CLI will auto-load this config. Just run: claude") - ); - } else { - println!( - "{}", - info(&format!( - "Use it with: claude --settings {}", - output_path.display() - )) - ); - } - - Ok(()) -} diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index bc4c19ae..e13cf1a0 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -2536,6 +2536,10 @@ fn prompt_opencode_config(current: Option<&Value>) -> Result { ) } +#[expect( + clippy::too_many_arguments, + reason = "OpenCode settings builder maps flat form fields into nested JSON" +)] fn build_opencode_settings_config( current: Option<&Value>, npm: &str, @@ -2648,9 +2652,7 @@ fn opencode_primary_model_id(model_id: &str, model_name: &str) -> Option None } -fn opencode_selected_model_from_models<'a>( - models: &'a Map, -) -> Option<(&'a String, &'a Value)> { +fn opencode_selected_model_from_models(models: &Map) -> Option<(&String, &Value)> { models.iter().max_by(|(id_a, model_a), (id_b, model_b)| { opencode_model_rank(model_a) .cmp(&opencode_model_rank(model_b)) @@ -2897,10 +2899,7 @@ fn build_hermes_settings_config( fn normalize_hermes_api_mode(api_mode: &str) -> String { let api_mode = api_mode.trim(); - if crate::hermes_config::HERMES_API_MODES - .iter() - .any(|candidate| *candidate == api_mode) - { + if crate::hermes_config::HERMES_API_MODES.contains(&api_mode) { api_mode.to_string() } else { crate::hermes_config::HERMES_DEFAULT_API_MODE.to_string() @@ -3284,8 +3283,6 @@ pub fn generate_provider_id(name: &str, existing_ids: &[String]) -> String { .map(|c| { if c.is_alphanumeric() || c == '-' || c == '_' { c - } else if c.is_whitespace() { - '-' } else { '-' } diff --git a/src-tauri/src/cli/commands/proxy.rs b/src-tauri/src/cli/commands/proxy.rs index 9164dba5..fac12653 100644 --- a/src-tauri/src/cli/commands/proxy.rs +++ b/src-tauri/src/cli/commands/proxy.rs @@ -683,8 +683,10 @@ mod tests { config: RwLock::new(MultiAppConfig::default()), proxy_service: ProxyService::new(db.clone()), }; - let mut config = crate::ProxyConfig::default(); - config.listen_port = 15721; + let config = crate::ProxyConfig { + listen_port: 15721, + ..Default::default() + }; db.set_proxy_flags_sync("claude", true, false) .expect("enable claude proxy route"); db.set_app_proxy_preferred_port("codex", 15722) diff --git a/src-tauri/src/cli/commands/update.rs b/src-tauri/src/cli/commands/update.rs index 2169768e..d18fea9e 100644 --- a/src-tauri/src/cli/commands/update.rs +++ b/src-tauri/src/cli/commands/update.rs @@ -643,7 +643,7 @@ fn asset_name_from_url(url: &str) -> Result { .map_err(|e| AppError::Message(format!("Invalid asset URL '{url}': {e}")))?; let asset_name = parsed .path_segments() - .and_then(|segments| segments.last()) + .and_then(|mut segments| segments.next_back()) .filter(|value| !value.is_empty()) .ok_or_else(|| AppError::Message(format!("Asset URL has no file name: {url}")))?; diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 5a6e8fef..1d568e70 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -1,3 +1,8 @@ +#![expect( + clippy::if_same_then_else, + reason = "generated i18n accessors may share text across locales" +)] + use crate::settings::{get_settings, update_settings}; use std::sync::OnceLock; use std::sync::RwLock; @@ -3061,12 +3066,10 @@ pub mod texts { } else { "选择模型".to_string() } + } else if fetching { + "Select Model (Fetching...)".to_string() } else { - if fetching { - "Select Model (Fetching...)".to_string() - } else { - "Select Model".to_string() - } + "Select Model".to_string() } } @@ -6926,12 +6929,10 @@ pub mod texts { } else { "仓库已禁用。".to_string() } + } else if enabled { + "Repository enabled.".to_string() } else { - if enabled { - "Repository enabled.".to_string() - } else { - "Repository disabled.".to_string() - } + "Repository disabled.".to_string() } } @@ -6942,12 +6943,10 @@ pub mod texts { } else { "已恢复 Claude Code 初次安装确认。".to_string() } + } else if enabled { + "Claude Code onboarding confirmation will be skipped.".to_string() } else { - if enabled { - "Claude Code onboarding confirmation will be skipped.".to_string() - } else { - "Claude Code onboarding confirmation restored.".to_string() - } + "Claude Code onboarding confirmation restored.".to_string() } } @@ -6958,12 +6957,10 @@ pub mod texts { } else { "已关闭 Claude Code for VSCode 插件联动。".to_string() } + } else if enabled { + "Claude Code for VSCode integration enabled.".to_string() } else { - if enabled { - "Claude Code for VSCode integration enabled.".to_string() - } else { - "Claude Code for VSCode integration disabled.".to_string() - } + "Claude Code for VSCode integration disabled.".to_string() } } @@ -10186,16 +10183,14 @@ pub mod texts { "确认恢复 Claude Code 初次安装确认?\n将从 {path} 删除 hasCompletedOnboarding" ) } + } else if enable { + format!( + "Enable skipping Claude Code onboarding confirmation?\nWrites hasCompletedOnboarding=true to {path}" + ) } else { - if enable { - format!( - "Enable skipping Claude Code onboarding confirmation?\nWrites hasCompletedOnboarding=true to {path}" - ) - } else { - format!( - "Disable skipping Claude Code onboarding confirmation?\nRemoves hasCompletedOnboarding from {path}" - ) - } + format!( + "Disable skipping Claude Code onboarding confirmation?\nRemoves hasCompletedOnboarding from {path}" + ) } } @@ -10206,12 +10201,10 @@ pub mod texts { } else { "✓ 已恢复 Claude Code 初次安装确认".to_string() } + } else if enable { + "✓ Skip Claude Code onboarding confirmation enabled".to_string() } else { - if enable { - "✓ Skip Claude Code onboarding confirmation enabled".to_string() - } else { - "✓ Claude Code onboarding confirmation restored".to_string() - } + "✓ Claude Code onboarding confirmation restored".to_string() } } @@ -10240,16 +10233,14 @@ pub mod texts { } else { "确认关闭 Claude Code for VSCode 插件联动?".to_string() } + } else if enable { + format!( + "Enable Claude Code for VSCode integration?\nWrites primaryApiKey=\"any\" to {path}" + ) } else { - if enable { - format!( - "Enable Claude Code for VSCode integration?\nWrites primaryApiKey=\"any\" to {path}" - ) - } else { - format!( - "Disable Claude Code for VSCode integration?\nRemoves primaryApiKey from {path}" - ) - } + format!( + "Disable Claude Code for VSCode integration?\nRemoves primaryApiKey from {path}" + ) } } @@ -10260,12 +10251,10 @@ pub mod texts { } else { "✓ 已关闭 Claude Code for VSCode 插件联动".to_string() } + } else if enable { + "✓ Claude Code for VSCode integration enabled".to_string() } else { - if enable { - "✓ Claude Code for VSCode integration enabled".to_string() - } else { - "✓ Claude Code for VSCode integration disabled".to_string() - } + "✓ Claude Code for VSCode integration disabled".to_string() } } @@ -10497,12 +10486,10 @@ pub mod texts { } else { format!("编辑 {app} 的通用配置片段(JSON 对象,留空则清除):") } + } else if is_codex { + format!("Edit common config snippet for {app} (TOML; empty to clear):") } else { - if is_codex { - format!("Edit common config snippet for {app} (TOML; empty to clear):") - } else { - format!("Edit common config snippet for {app} (JSON object; empty to clear):") - } + format!("Edit common config snippet for {app} (JSON object; empty to clear):") } } diff --git a/src-tauri/src/cli/openclaw_form_normalization.rs b/src-tauri/src/cli/openclaw_form_normalization.rs index 84cd9f52..45ac98fb 100644 --- a/src-tauri/src/cli/openclaw_form_normalization.rs +++ b/src-tauri/src/cli/openclaw_form_normalization.rs @@ -249,7 +249,7 @@ pub(crate) fn preserved_non_string_runtime_seed<'a>( value .trim() .is_empty() - .then(|| seed) + .then_some(seed) .flatten() .filter(|seed| should_preserve_non_string_numeric_seed(Some(seed))) } diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index c8a88485..b83002b0 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -72,6 +72,10 @@ pub enum Action { ProviderSwitch { id: String, }, + ProviderSwitchResolveLiveConflicts { + id: String, + policy: crate::services::provider::live_merge::ConflictPolicy, + }, ProviderRemoveFromConfig { id: String, }, diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index b44c87bc..d5f1d776 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -544,7 +544,7 @@ impl App { }) } else { let options = form.available_fallback_options(&model_options); - (!options.is_empty()).then(|| (row, 0, options)) + (!options.is_empty()).then_some((row, 0, options)) } } OpenClawAgentsSection::Runtime => None, diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs index 2079e548..25008eb2 100644 --- a/src-tauri/src/cli/tui/app/editor_state.rs +++ b/src-tauri/src/cli/tui/app/editor_state.rs @@ -147,11 +147,7 @@ impl EditorState { current_width = current_width.saturating_add(ch_width); } - if segments.is_empty() { - segments.push(current); - } else { - segments.push(current); - } + segments.push(current); segments } diff --git a/src-tauri/src/cli/tui/app/form_handlers/mcp.rs b/src-tauri/src/cli/tui/app/form_handlers/mcp.rs index 5c3e11dd..1674ee67 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/mcp.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/mcp.rs @@ -73,10 +73,7 @@ impl App { } fn handle_mcp_fields_key(&mut self, key: KeyEvent) -> Option { - let (fields, selected, editing) = match self.prepare_mcp_field_selection() { - Some(state) => state, - None => return None, - }; + let (fields, selected, editing) = self.prepare_mcp_field_selection()?; if editing { self.handle_mcp_field_editing(selected, key) @@ -96,9 +93,7 @@ impl App { Some(Action::None) } _ => { - if TextEditCommand::from_key(key).is_none() { - return None; - } + TextEditCommand::from_key(key)?; if let Some(input) = mcp.input_mut(selected) { input.apply_key(key); } diff --git a/src-tauri/src/cli/tui/app/form_handlers/prompt.rs b/src-tauri/src/cli/tui/app/form_handlers/prompt.rs index 226345a3..aabd3e49 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/prompt.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/prompt.rs @@ -86,10 +86,7 @@ impl App { } fn handle_prompt_meta_fields_key(&mut self, key: KeyEvent) -> Option { - let (fields, selected, editing) = match self.prepare_prompt_meta_field_selection() { - Some(state) => state, - None => return None, - }; + let (fields, selected, editing) = self.prepare_prompt_meta_field_selection()?; if editing { self.handle_prompt_meta_field_editing(selected, key) @@ -113,9 +110,7 @@ impl App { Some(Action::None) } _ => { - if TextEditCommand::from_key(key).is_none() { - return None; - } + TextEditCommand::from_key(key)?; prompt.input_mut(selected).apply_key(key); Some(Action::None) } diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index 52615789..c9cec82b 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -136,10 +136,7 @@ impl App { } fn handle_provider_fields_key(&mut self, key: KeyEvent, data: &UiData) -> Option { - let (fields, selected, editing) = match self.prepare_provider_field_selection() { - Some(state) => state, - None => return None, - }; + let (fields, selected, editing) = self.prepare_provider_field_selection()?; if editing { self.handle_provider_field_editing(selected, key, data) @@ -164,9 +161,7 @@ impl App { Some(Action::None) } _ => { - if TextEditCommand::from_key(key).is_none() { - return None; - } + TextEditCommand::from_key(key)?; let policy = TextInputPolicy { max_chars: (selected == ProviderAddField::Notes) .then_some(PROVIDER_NOTES_MAX_CHARS), @@ -674,10 +669,7 @@ impl App { }; } - let (fields, selected, editing) = match self.prepare_usage_query_field_selection() { - Some(state) => state, - None => return None, - }; + let (fields, selected, editing) = self.prepare_usage_query_field_selection()?; if editing { self.handle_usage_query_field_editing(selected, key) @@ -724,9 +716,7 @@ impl App { Some(Action::None) } _ => { - if TextEditCommand::from_key(key).is_none() { - return None; - } + TextEditCommand::from_key(key)?; let changed = provider .usage_query_input_mut(selected) .and_then(|input| input.apply_key(key)) diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index 17c23202..b03b13d7 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -817,9 +817,9 @@ pub(crate) fn visible_sessions_for_state<'a>( .collect() } -pub(crate) fn visible_session_messages<'a>( - sessions: &'a SessionsState, -) -> Vec<(usize, &'a crate::session_manager::SessionMessage)> { +pub(crate) fn visible_session_messages( + sessions: &SessionsState, +) -> Vec<(usize, &crate::session_manager::SessionMessage)> { let query = sessions.message_query_lower(); sessions .messages diff --git a/src-tauri/src/cli/tui/app/menu.rs b/src-tauri/src/cli/tui/app/menu.rs index e81df2a8..061b1898 100644 --- a/src-tauri/src/cli/tui/app/menu.rs +++ b/src-tauri/src/cli/tui/app/menu.rs @@ -318,7 +318,8 @@ impl App { } pub(crate) fn should_poll_proxy_activity(&self) -> bool { - matches!(self.route, Route::Main) && self.tick % PROXY_ACTIVITY_POLL_INTERVAL_TICKS == 0 + matches!(self.route, Route::Main) + && self.tick.is_multiple_of(PROXY_ACTIVITY_POLL_INTERVAL_TICKS) } pub(crate) fn reset_proxy_activity(&mut self, input_tokens: u64, output_tokens: u64) { diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs index c09003f1..56c3d5e5 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs @@ -10,6 +10,10 @@ impl App { return Some(action); } + if let Some(action) = self.handle_provider_switch_live_conflicts_overlay_key(key) { + return Some(action); + } + if let Some(action) = self.handle_text_input_overlay_key(key, data) { return Some(action); } @@ -217,6 +221,44 @@ impl App { Some(action) } + fn handle_provider_switch_live_conflicts_overlay_key( + &mut self, + key: KeyEvent, + ) -> Option { + let Overlay::ProviderSwitchLiveConflicts { provider_id, .. } = &self.overlay else { + return None; + }; + let id = provider_id.clone(); + + let action = match key.code { + KeyCode::Char('l') | KeyCode::Char('L') | KeyCode::Char('1') => { + self.close_overlay(); + Action::ProviderSwitchResolveLiveConflicts { + id, + policy: crate::services::provider::live_merge::ConflictPolicy::PreferLocal, + } + } + KeyCode::Char('c') + | KeyCode::Char('C') + | KeyCode::Char('u') + | KeyCode::Char('U') + | KeyCode::Char('2') => { + self.close_overlay(); + Action::ProviderSwitchResolveLiveConflicts { + id, + policy: crate::services::provider::live_merge::ConflictPolicy::PreferIncoming, + } + } + KeyCode::Esc => { + self.close_overlay(); + Action::None + } + _ => Action::None, + }; + + Some(action) + } + fn handle_text_input_overlay_key(&mut self, key: KeyEvent, data: &UiData) -> Option { let Overlay::TextInput(input) = &self.overlay else { return None; diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 3e343263..9b410461 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -1,6 +1,10 @@ use super::*; #[cfg(test)] +#[expect( + clippy::module_inception, + reason = "test module mirrors the file layout" +)] mod tests { use super::types::{McpEnvEditorField, McpEnvEntryEditorState}; use super::*; @@ -37,8 +41,10 @@ mod tests { impl SettingsGuard { fn with_openclaw_dir(path: &Path) -> Self { let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.openclaw_config_dir = Some(path.display().to_string()); + let settings = AppSettings { + openclaw_config_dir: Some(path.display().to_string()), + ..Default::default() + }; update_settings(settings).expect("set openclaw override dir"); Self { previous } } @@ -911,7 +917,7 @@ mod tests { assert!(imports[0].apps.claude); assert!(imports[0].apps.opencode); assert!( - imports[0].apps.is_empty() == false, + !imports[0].apps.is_empty(), "supported app targets should be preserved" ); assert!( @@ -1271,9 +1277,9 @@ mod tests { #[test] fn filter_mode_updates_buffer_and_exits() { let mut app = App::new(Some(AppType::Claude)); - assert_eq!(app.filter.active, false); + assert!(!app.filter.active); app.on_key(key(KeyCode::Char('/')), &data()); - assert_eq!(app.filter.active, true); + assert!(app.filter.active); app.on_key(key(KeyCode::Char('a')), &data()); app.on_key(key(KeyCode::Char('b')), &data()); app.on_key(key(KeyCode::Char('j')), &data()); @@ -1282,7 +1288,7 @@ mod tests { app.on_key(key(KeyCode::Backspace), &data()); assert_eq!(app.filter.input.value, "abj"); app.on_key(key(KeyCode::Enter), &data()); - assert_eq!(app.filter.active, false); + assert!(!app.filter.active); } #[test] diff --git a/src-tauri/src/cli/tui/app/types.rs b/src-tauri/src/cli/tui/app/types.rs index e185c4b3..dc31e3de 100644 --- a/src-tauri/src/cli/tui/app/types.rs +++ b/src-tauri/src/cli/tui/app/types.rs @@ -609,6 +609,10 @@ pub enum Overlay { provider_id: String, selected: usize, }, + ProviderSwitchLiveConflicts { + provider_id: String, + conflicts: Vec, + }, FailoverQueueManager { selected: usize, }, @@ -736,6 +740,7 @@ impl Overlay { | Overlay::TextView(_) | Overlay::CommonSnippetPicker { .. } | Overlay::ProviderTestMenu { .. } + | Overlay::ProviderSwitchLiveConflicts { .. } | Overlay::FailoverQueueManager { .. } | Overlay::ClaudeApiFormatPicker { .. } | Overlay::UsageQueryTemplatePicker { .. } @@ -775,6 +780,7 @@ impl Overlay { | Overlay::TextView(_) | Overlay::CommonSnippetPicker { .. } | Overlay::ProviderTestMenu { .. } + | Overlay::ProviderSwitchLiveConflicts { .. } | Overlay::FailoverQueueManager { .. } | Overlay::ClaudeApiFormatPicker { .. } | Overlay::UsageQueryTemplatePicker { .. } diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index 772b4552..47cea946 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -327,9 +327,10 @@ impl ProxySnapshot { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum UsageRangePreset { Today, + #[default] SevenDays, ThirtyDays, Custom(UsageCustomRange), @@ -392,12 +393,6 @@ impl UsageCustomRange { } } -impl Default for UsageRangePreset { - fn default() -> Self { - Self::SevenDays - } -} - pub(crate) fn usage_custom_range_default_input() -> String { let today = Local::now().date_naive(); let start = today.checked_sub_days(Days::new(6)).unwrap_or(today); @@ -828,7 +823,7 @@ impl UsageSnapshot { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct UiData { pub providers: ProvidersSnapshot, pub mcp: McpSnapshot, @@ -842,23 +837,6 @@ pub struct UiData { pub(crate) reload_token: UiDataReloadToken, } -impl Default for UiData { - fn default() -> Self { - Self { - providers: ProvidersSnapshot::default(), - mcp: McpSnapshot::default(), - prompts: PromptsSnapshot::default(), - config: ConfigSnapshot::default(), - skills: SkillsSnapshot::default(), - proxy: ProxySnapshot::default(), - usage: UsageSnapshot::default(), - pricing: ModelPricingSnapshot::default(), - quota: QuotaSnapshot::default(), - reload_token: UiDataReloadToken::default(), - } - } -} - pub(crate) fn load_state() -> Result { AppState::try_new() } @@ -2686,16 +2664,20 @@ mod tests { impl SettingsGuard { fn with_opencode_dir(path: &Path) -> Self { let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.opencode_config_dir = Some(path.display().to_string()); + let settings = AppSettings { + opencode_config_dir: Some(path.display().to_string()), + ..Default::default() + }; update_settings(settings).expect("set opencode override dir"); Self { previous } } fn with_openclaw_dir(path: &Path) -> Self { let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.openclaw_config_dir = Some(path.display().to_string()); + let settings = AppSettings { + openclaw_config_dir: Some(path.display().to_string()), + ..Default::default() + }; update_settings(settings).expect("set openclaw override dir"); Self { previous } } @@ -2755,6 +2737,10 @@ mod tests { Ok(()) } + #[expect( + clippy::too_many_arguments, + reason = "test helper mirrors usage log columns" + )] fn insert_usage_log( conn: &rusqlite::Connection, request_id: &str, diff --git a/src-tauri/src/cli/tui/form.rs b/src-tauri/src/cli/tui/form.rs index 6bdd2341..9c6cb23c 100644 --- a/src-tauri/src/cli/tui/form.rs +++ b/src-tauri/src/cli/tui/form.rs @@ -119,7 +119,7 @@ impl ClaudeApiFormat { Self::choices_for_app(app_type) .get(index) .copied() - .unwrap_or_else(|| { + .unwrap_or({ if matches!(app_type, AppType::Codex) { ClaudeApiFormat::OpenAiResponses } else { @@ -549,6 +549,10 @@ impl PromptMetaFormState { } } +#[expect( + clippy::large_enum_variant, + reason = "form state variants are short-lived UI state" +)] #[derive(Debug, Clone)] pub enum FormState { ProviderAdd(ProviderAddFormState), diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index fb687c4a..c36d5e6b 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -310,7 +310,7 @@ fn populate_hermes_form(form: &mut ProviderAddFormState, provider: &Provider) { .or_else(|| settings.get("apiMode")) .and_then(|value| value.as_str()) { - if super::HERMES_API_MODES.iter().any(|mode| *mode == api_mode) { + if super::HERMES_API_MODES.contains(&api_mode) { form.hermes_api_mode = api_mode.to_string(); } } diff --git a/src-tauri/src/cli/tui/mod.rs b/src-tauri/src/cli/tui/mod.rs index 6de6c512..da959260 100644 --- a/src-tauri/src/cli/tui/mod.rs +++ b/src-tauri/src/cli/tui/mod.rs @@ -973,6 +973,7 @@ fn cache_invalidation_for_action(action: &Action) -> CacheInvalidation { | Action::ConfigWebDavMigrateV1ToV2 => CacheInvalidation::AppStateRecreated, Action::ProviderSwitch { .. } + | Action::ProviderSwitchResolveLiveConflicts { .. } | Action::ProviderRemoveFromConfig { .. } | Action::ProviderSetDefaultModel { .. } | Action::ProviderImportLiveConfig @@ -1071,6 +1072,35 @@ fn drop_cached_worker_state( Ok(()) } +fn apply_current_app_data_changed( + app: &mut App, + data: &mut data::UiData, + data_cache: &mut UiDataByAppCache, + quota_req_tx: Option<&mpsc::Sender>, + app_data_req_tx: Option<&mpsc::Sender>, + usage_pricing_req_tx: Option<&mpsc::Sender>, +) -> Result<(), AppError> { + let app_type = app.app_type.clone(); + data_cache.remove_app_snapshot(&app_type); + data_cache.remove_usage_pricing_for_app(&app_type); + + match data_cache.queue_current_app_data_refresh(app_data_req_tx, &app_type) { + AppDataLoadQueued::Queued | AppDataLoadQueued::AlreadyPending => Ok(()), + AppDataLoadQueued::Unavailable | AppDataLoadQueued::SendFailed => { + *data = data::UiData::load(&app_type)?; + data_cache.mark_app_data_loaded(&app_type); + apply_loaded_data_cache_invalidation( + app, + data, + data_cache, + quota_req_tx, + usage_pricing_req_tx, + CacheInvalidation::DataReloaded, + ) + } + } +} + fn apply_cache_invalidation( app: &mut App, data: &mut data::UiData, @@ -1145,35 +1175,10 @@ fn apply_loaded_data_cache_invalidation( Ok(()) } -fn apply_current_app_data_changed( - app: &mut App, - data: &mut data::UiData, - data_cache: &mut UiDataByAppCache, - quota_req_tx: Option<&mpsc::Sender>, - app_data_req_tx: Option<&mpsc::Sender>, - usage_pricing_req_tx: Option<&mpsc::Sender>, -) -> Result<(), AppError> { - let current_app_type = app.app_type.clone(); - data_cache.remove_app_snapshot(¤t_app_type); - data_cache.remove_usage_pricing_for_app(¤t_app_type); - - match data_cache.queue_current_app_data_refresh(app_data_req_tx, ¤t_app_type) { - AppDataLoadQueued::Queued | AppDataLoadQueued::AlreadyPending => Ok(()), - AppDataLoadQueued::Unavailable | AppDataLoadQueued::SendFailed => { - data_cache.remove_app_snapshot(¤t_app_type); - *data = data::UiData::load(¤t_app_type)?; - apply_loaded_data_cache_invalidation( - app, - data, - data_cache, - quota_req_tx, - usage_pricing_req_tx, - CacheInvalidation::DataReloaded, - ) - } - } -} - +#[expect( + clippy::too_many_arguments, + reason = "top-level TUI dispatcher coordinates worker channels, cache, and trackers" +)] fn handle_tui_action( terminal: &mut TuiTerminal, app: &mut App, diff --git a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs index d8bc535a..a465b8da 100644 --- a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs +++ b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs @@ -113,14 +113,10 @@ mod tests { use crate::cli::tui::data::{ProviderRow, ProvidersSnapshot, UiData}; use crate::cli::tui::runtime_systems::RequestTracker; use crate::provider::Provider; - use crate::test_support::{ - lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock, - }; + use crate::test_support::TestEnvGuard; use serde_json::{json, Value}; use serial_test::serial; use std::cell::Cell; - use std::ffi::OsString; - use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; @@ -174,44 +170,6 @@ mod tests { } } - struct EnvGuard { - _lock: TestHomeSettingsLock, - old_home: Option, - old_userprofile: Option, - } - - impl EnvGuard { - fn set_home(home: &Path) -> Self { - let lock = lock_test_home_and_settings(); - let old_home = std::env::var_os("HOME"); - let old_userprofile = std::env::var_os("USERPROFILE"); - std::env::set_var("HOME", home); - std::env::set_var("USERPROFILE", home); - set_test_home_override(Some(home)); - crate::settings::reload_test_settings(); - Self { - _lock: lock, - old_home, - old_userprofile, - } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.old_home { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - match &self.old_userprofile { - Some(value) => std::env::set_var("USERPROFILE", value), - None => std::env::remove_var("USERPROFILE"), - } - set_test_home_override(self.old_home.as_deref().map(Path::new)); - crate::settings::reload_test_settings(); - } - } - fn provider_row(id: &str, env: Value) -> ProviderRow { ProviderRow { id: id.to_string(), @@ -415,7 +373,7 @@ mod tests { #[serial] fn launch_uses_effective_snapshot_from_realtime_state() { let temp_home = TempDir::new().expect("create temp home"); - let _env = EnvGuard::set_home(temp_home.path()); + let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::config::get_claude_config_dir()) .expect("create ~/.claude (initialized)"); diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index 167c075e..37c65827 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -11,6 +11,7 @@ use crate::openclaw_config::{ OpenClawEnvConfig, OpenClawToolsConfig, }; use crate::provider::Provider; +use crate::services::provider::live_merge; use crate::services::{McpService, PromptService, ProviderService}; use crate::settings::{set_webdav_sync_settings, WebDavSyncSettings}; @@ -796,7 +797,12 @@ fn submit_provider_add( ) .map(|_| true) } else { - ProviderService::add(&state, ctx.app.app_type.clone(), provider) + ProviderService::add_with_resolution( + &state, + ctx.app.app_type.clone(), + provider, + live_merge::ConflictPolicy::PreferIncoming.into(), + ) }; match result { @@ -842,7 +848,12 @@ fn submit_provider_edit( } let state = load_state()?; - let result = ProviderService::update(&state, ctx.app.app_type.clone(), provider); + let result = ProviderService::update_with_resolution( + &state, + ctx.app.app_type.clone(), + provider, + live_merge::ConflictPolicy::PreferIncoming.into(), + ); if let Err(err) = result { ctx.app.push_toast(err.to_string(), ToastKind::Error); @@ -1183,8 +1194,10 @@ mod tests { impl SettingsGuard { fn with_openclaw_dir(path: &Path) -> Self { let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.openclaw_config_dir = Some(path.display().to_string()); + let settings = AppSettings { + openclaw_config_dir: Some(path.display().to_string()), + ..Default::default() + }; update_settings(settings).expect("set openclaw override dir"); Self { previous } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index f8fc914c..e47e1af8 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -141,6 +141,10 @@ pub(super) struct RuntimeActionContext<'a> { managed_auth_req_tx: Option<&'a mpsc::Sender>, } +#[expect( + clippy::too_many_arguments, + reason = "TUI dispatcher receives independent worker channels and request trackers" +)] pub(crate) fn handle_action( terminal: &mut TuiTerminal, app: &mut App, @@ -366,6 +370,9 @@ pub(crate) fn handle_action( } Action::EditorSubmit { submit, content } => editor::submit(&mut ctx, submit, content), Action::ProviderSwitch { id } => providers::switch(&mut ctx, id), + Action::ProviderSwitchResolveLiveConflicts { id, policy } => { + providers::switch_with_conflict_policy(&mut ctx, id, policy) + } Action::ProviderRemoveFromConfig { id } => providers::remove_from_config(&mut ctx, id), Action::ProviderSetDefaultModel { provider_id, diff --git a/src-tauri/src/cli/tui/runtime_actions/providers.rs b/src-tauri/src/cli/tui/runtime_actions/providers.rs index 6607050a..cd97432d 100644 --- a/src-tauri/src/cli/tui/runtime_actions/providers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/providers.rs @@ -4,6 +4,7 @@ use crate::error::AppError; #[cfg(test)] use crate::openclaw_config::OpenClawDefaultModel; use crate::proxy::providers::get_claude_api_format; +use crate::services::provider::live_merge::ConflictPolicy; use crate::services::provider::ProviderSortUpdate; use crate::services::ProviderService; @@ -98,7 +99,27 @@ fn refresh_provider_data_after_write_with_config( } pub(super) fn switch(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(), AppError> { - do_switch(ctx, id) + let state = load_state()?; + let conflicts = + ProviderService::preview_switch_live_conflicts(&state, ctx.app.app_type.clone(), &id)?; + if !conflicts.is_empty() { + ctx.app.overlay = Overlay::ProviderSwitchLiveConflicts { + provider_id: id, + conflicts, + }; + return Ok(()); + } + + do_switch(ctx, state, id, ConflictPolicy::Fail) +} + +pub(super) fn switch_with_conflict_policy( + ctx: &mut RuntimeActionContext<'_>, + id: String, + policy: ConflictPolicy, +) -> Result<(), AppError> { + let state = load_state()?; + do_switch(ctx, state, id, policy) } pub(super) fn import_live_config(ctx: &mut RuntimeActionContext<'_>) -> Result<(), AppError> { @@ -120,8 +141,12 @@ pub(super) fn import_live_config(ctx: &mut RuntimeActionContext<'_>) -> Result<( Ok(()) } -fn do_switch(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(), AppError> { - let state = load_state()?; +fn do_switch( + ctx: &mut RuntimeActionContext<'_>, + state: crate::store::AppState, + id: String, + policy: ConflictPolicy, +) -> Result<(), AppError> { let switched_provider = ctx .data .providers @@ -129,7 +154,7 @@ fn do_switch(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(), AppEr .iter() .find(|row| row.id == id) .map(|row| row.provider.clone()); - ProviderService::switch(&state, ctx.app.app_type.clone(), &id)?; + ProviderService::switch_with_resolution(&state, ctx.app.app_type.clone(), &id, policy.into())?; if let Some(provider) = switched_provider.as_ref() { if let Err(err) = crate::claude_plugin::sync_claude_plugin_on_provider_switch(&ctx.app.app_type, provider) @@ -453,7 +478,7 @@ pub(super) fn model_fetch( ctx.app.overlay = Overlay::ModelFetchPicker { request_id, - field: field.clone(), + field, claude_idx, input: TextInput::new(""), query: String::new(), @@ -506,13 +531,15 @@ mod tests { use crate::test_support::{ lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock, }; - use crate::{AppType, MultiAppConfig}; + use crate::{AppState, AppType, MultiAppConfig}; struct EnvGuard { _lock: TestHomeSettingsLock, old_home: Option, old_userprofile: Option, old_config_dir: Option, + old_claude_config_dir: Option, + old_codex_home: Option, } impl EnvGuard { @@ -521,9 +548,13 @@ mod tests { let old_home = std::env::var_os("HOME"); let old_userprofile = std::env::var_os("USERPROFILE"); let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR"); + let old_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR"); + let old_codex_home = std::env::var_os("CODEX_HOME"); std::env::set_var("HOME", home); std::env::set_var("USERPROFILE", home); std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch")); + std::env::set_var("CLAUDE_CONFIG_DIR", home.join(".claude")); + std::env::set_var("CODEX_HOME", home.join(".codex")); set_test_home_override(Some(home)); crate::settings::reload_test_settings(); Self { @@ -531,6 +562,8 @@ mod tests { old_home, old_userprofile, old_config_dir, + old_claude_config_dir, + old_codex_home, } } } @@ -549,6 +582,14 @@ mod tests { Some(value) => std::env::set_var("CC_SWITCH_CONFIG_DIR", value), None => std::env::remove_var("CC_SWITCH_CONFIG_DIR"), } + match &self.old_claude_config_dir { + Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), + } + match &self.old_codex_home { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } set_test_home_override(self.old_home.as_deref().map(Path::new)); crate::settings::reload_test_settings(); } @@ -561,24 +602,30 @@ mod tests { impl SettingsGuard { fn with_opencode_dir(path: &Path) -> Self { let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.opencode_config_dir = Some(path.display().to_string()); + let settings = AppSettings { + opencode_config_dir: Some(path.display().to_string()), + ..Default::default() + }; update_settings(settings).expect("set opencode override dir"); Self { previous } } fn with_openclaw_dir(path: &Path) -> Self { let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.openclaw_config_dir = Some(path.display().to_string()); + let settings = AppSettings { + openclaw_config_dir: Some(path.display().to_string()), + ..Default::default() + }; update_settings(settings).expect("set openclaw override dir"); Self { previous } } fn with_hermes_dir(path: &Path) -> Self { let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.hermes_config_dir = Some(path.display().to_string()); + let settings = AppSettings { + hermes_config_dir: Some(path.display().to_string()), + ..Default::default() + }; update_settings(settings).expect("set hermes override dir"); Self { previous } } @@ -711,6 +758,8 @@ mod tests { data: UiData, } + const MATCHING_CODEX_LIVE_CONFIG: &str = "model_provider = \"latest\"\nmodel = \"gpt-5.2-codex\"\n\n[model_providers.latest]\nbase_url = \"https://api.example.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\n[projects.local]\ntrust_level = \"trusted\"\n"; + fn run_codex_switch( current_id: &str, config_text: Option<&str>, @@ -782,7 +831,9 @@ mod tests { if seed_live { seed_claude_live_settings(json!({ "env": { - "ANTHROPIC_API_KEY": "live-key" + "ANTHROPIC_BASE_URL": "https://example.com", + "ANTHROPIC_API_KEY": "sk-new", + "LOCAL_ONLY": "preserve-me" }, "permissions": { "allow": ["Bash"] @@ -836,6 +887,16 @@ mod tests { ) } + fn add_claude_queue_provider(state: &AppState, id: &str) -> Result<(), AppError> { + seed_claude_live_settings(json!({ + "env": { + "ANTHROPIC_BASE_URL": format!("https://{id}.example.com"), + "LOCAL_ONLY": "preserve-me" + } + }))?; + ProviderService::add(state, AppType::Claude, claude_queue_provider(id)).map(|_| ()) + } + fn reload_fixture_data(fixture: &mut RuntimeActionFixture) { fixture.data = UiData::load(&fixture.app.app_type).expect("reload ui data"); } @@ -890,8 +951,7 @@ mod tests { let _env = EnvGuard::set_home(temp_home.path()); let state = load_state().expect("load state"); - ProviderService::add(&state, AppType::Claude, claude_queue_provider("p1")) - .expect("add provider"); + add_claude_queue_provider(&state, "p1").expect("add provider"); state .db .add_to_failover_queue("claude", "p1") @@ -929,8 +989,7 @@ mod tests { let _env = EnvGuard::set_home(temp_home.path()); let state = load_state().expect("load state"); - ProviderService::add(&state, AppType::Claude, claude_queue_provider("p1")) - .expect("add provider"); + add_claude_queue_provider(&state, "p1").expect("add provider"); state .db .add_to_failover_queue("claude", "p1") @@ -968,10 +1027,8 @@ mod tests { let _env = EnvGuard::set_home(temp_home.path()); let state = load_state().expect("load state"); - ProviderService::add(&state, AppType::Claude, claude_queue_provider("p1")) - .expect("add first provider"); - ProviderService::add(&state, AppType::Claude, claude_queue_provider("p2")) - .expect("add second provider"); + add_claude_queue_provider(&state, "p1").expect("add first provider"); + add_claude_queue_provider(&state, "p2").expect("add second provider"); state .db .add_to_failover_queue("claude", "p1") @@ -1016,10 +1073,8 @@ mod tests { let _env = EnvGuard::set_home(temp_home.path()); let state = load_state().expect("load state"); - ProviderService::add(&state, AppType::Claude, claude_queue_provider("p1")) - .expect("add first provider"); - ProviderService::add(&state, AppType::Claude, claude_queue_provider("p2")) - .expect("add second provider"); + add_claude_queue_provider(&state, "p1").expect("add first provider"); + add_claude_queue_provider(&state, "p2").expect("add second provider"); state .db .add_to_failover_queue("claude", "p1") @@ -1059,8 +1114,7 @@ mod tests { let _env = EnvGuard::set_home(temp_home.path()); let state = load_state().expect("load state"); - ProviderService::add(&state, AppType::Claude, claude_queue_provider("p1")) - .expect("add provider"); + add_claude_queue_provider(&state, "p1").expect("add provider"); state .db .add_to_failover_queue("claude", "p1") @@ -1101,6 +1155,13 @@ mod tests { let _env = EnvGuard::set_home(temp_home.path()); let state = load_state().expect("load state"); + seed_claude_live_settings(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://example.com", + "LOCAL_ONLY": "preserve-me" + } + })) + .expect("seed live settings"); ProviderService::add( &state, AppType::Claude, @@ -1184,7 +1245,21 @@ mod tests { None, ); second.sort_index = Some(1); + seed_claude_live_settings(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://first.example.com", + "LOCAL_ONLY": "preserve-me" + } + })) + .expect("seed first live settings"); ProviderService::add(&state, AppType::Claude, first).expect("add first provider"); + seed_claude_live_settings(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://second.example.com", + "LOCAL_ONLY": "preserve-me" + } + })) + .expect("seed second live settings"); ProviderService::add(&state, AppType::Claude, second).expect("add second provider"); state .db @@ -1242,8 +1317,8 @@ mod tests { fn provider_switch_does_not_show_restart_toast_when_live_sync_succeeds() { let fixture = run_codex_switch( "old-provider", - Some("model_provider = \"legacy\"\nmodel = \"gpt-4\"\n"), - Some(json!({"OPENAI_API_KEY": "legacy-key"})), + Some(MATCHING_CODEX_LIVE_CONFIG), + Some(json!({"OPENAI_API_KEY": "fresh-key", "LOCAL_ONLY": "preserve-me"})), ) .expect("switch should succeed"); @@ -1269,12 +1344,8 @@ mod tests { #[test] #[serial(home_settings)] fn provider_switch_overwrites_existing_codex_settings_without_prompt() { - let fixture = run_codex_switch( - "", - Some("model_provider = \"legacy\"\nmodel = \"gpt-4\"\n"), - None, - ) - .expect("switch should succeed"); + let fixture = run_codex_switch("", Some(MATCHING_CODEX_LIVE_CONFIG), None) + .expect("switch should succeed"); assert_eq!(fixture.data.providers.current_id, "new-provider"); assert!(matches!(fixture.app.overlay, Overlay::None)); @@ -1292,8 +1363,12 @@ mod tests { #[test] #[serial(home_settings)] fn provider_switch_codex_auth_only_state_switches_normally() { - let fixture = run_codex_switch("", None, Some(json!({"OPENAI_API_KEY": "legacy-key"}))) - .expect("switch should succeed"); + let fixture = run_codex_switch( + "", + None, + Some(json!({"OPENAI_API_KEY": "fresh-key", "LOCAL_ONLY": "preserve-me"})), + ) + .expect("switch should succeed"); assert_eq!(fixture.data.providers.current_id, "new-provider"); assert!(matches!(fixture.app.overlay, Overlay::None)); @@ -1391,7 +1466,7 @@ mod tests { .and_then(|meta| meta.live_config_managed), Some(true) ); - assert!(matches!(ctx.app.toast, Some(_))); + assert!(ctx.app.toast.is_some()); remove_from_config(&mut ctx, "p1".to_string()) .expect("remove opencode provider from config"); @@ -1567,12 +1642,8 @@ mod tests { #[test] #[serial(home_settings)] fn provider_switch_existing_codex_install_with_current_provider_switches_normally() { - let fixture = run_codex_switch( - "old-provider", - Some("model_provider = \"legacy\"\nmodel = \"gpt-4\"\n"), - None, - ) - .expect("switch should succeed"); + let fixture = run_codex_switch("old-provider", Some(MATCHING_CODEX_LIVE_CONFIG), None) + .expect("switch should succeed"); assert_eq!(fixture.data.providers.current_id, "new-provider"); assert!(matches!(fixture.app.overlay, Overlay::None)); @@ -1586,7 +1657,7 @@ mod tests { seed_codex_live_files( Some("model_provider = \"legacy\"\nmodel = \"gpt-4\"\n"), - Some(json!({"OPENAI_API_KEY": "legacy-key"})), + Some(json!({"OPENAI_API_KEY": "fresh-key", "LOCAL_ONLY": "preserve-me"})), ) .expect("seed codex live files"); let mut terminal = TuiTerminal::new_for_test().expect("create terminal"); @@ -2249,7 +2320,7 @@ mod tests { remove_from_config(&mut ctx, "p2".to_string()) .expect("fallback-only default reference should be removable"); - assert!(matches!(ctx.app.toast, Some(_))); + assert!(ctx.app.toast.is_some()); assert!(!crate::openclaw_config::get_providers() .expect("read providers after successful remove") .contains_key("p2")); diff --git a/src-tauri/src/cli/tui/runtime_actions/settings.rs b/src-tauri/src/cli/tui/runtime_actions/settings.rs index 2d1bce6b..ae800b6c 100644 --- a/src-tauri/src/cli/tui/runtime_actions/settings.rs +++ b/src-tauri/src/cli/tui/runtime_actions/settings.rs @@ -550,8 +550,6 @@ mod tests { use super::*; use serde_json::json; - use std::ffi::OsString; - use std::path::Path; use tempfile::TempDir; use crate::app_config::AppType; @@ -561,52 +559,12 @@ mod tests { use crate::cli::tui::terminal::TuiTerminal; use crate::provider::Provider; use crate::services::ProviderService; - use crate::test_support::{ - lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock, - }; - - struct EnvGuard { - _lock: TestHomeSettingsLock, - old_home: Option, - old_userprofile: Option, - } - - impl EnvGuard { - fn set_home(home: &Path) -> Self { - let lock = lock_test_home_and_settings(); - let old_home = std::env::var_os("HOME"); - let old_userprofile = std::env::var_os("USERPROFILE"); - std::env::set_var("HOME", home); - std::env::set_var("USERPROFILE", home); - set_test_home_override(Some(home)); - crate::settings::reload_test_settings(); - Self { - _lock: lock, - old_home, - old_userprofile, - } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.old_home { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - match &self.old_userprofile { - Some(value) => std::env::set_var("USERPROFILE", value), - None => std::env::remove_var("USERPROFILE"), - } - set_test_home_override(self.old_home.as_deref().map(Path::new)); - crate::settings::reload_test_settings(); - } - } + use crate::test_support::TestEnvGuard; #[test] fn set_openclaw_config_dir_persists_override_and_syncs_live_config() { let temp_home = TempDir::new().expect("create temp home"); - let _env = EnvGuard::set_home(temp_home.path()); + let _env = TestEnvGuard::isolated(temp_home.path()); let target_dir = temp_home.path().join("wsl-openclaw"); std::fs::create_dir_all(&target_dir).expect("create target openclaw dir"); @@ -616,8 +574,8 @@ mod tests { &state, AppType::OpenClaw, Provider::with_id( - "demo".to_string(), - "Demo".to_string(), + "settings-dir-demo".to_string(), + "Settings Dir Demo".to_string(), json!({ "apiKey": "sk-demo", "baseUrl": "https://demo.example/v1", @@ -670,11 +628,11 @@ mod tests { let value: serde_json::Value = json5::from_str(&source).expect("parse synced openclaw config as json5"); assert_eq!( - value["models"]["providers"]["demo"]["baseUrl"], + value["models"]["providers"]["settings-dir-demo"]["baseUrl"], json!("https://demo.example/v1") ); assert_eq!( - value["models"]["providers"]["demo"]["models"][0]["id"], + value["models"]["providers"]["settings-dir-demo"]["models"][0]["id"], json!("demo-model") ); } @@ -682,7 +640,7 @@ mod tests { #[test] fn set_openclaw_config_dir_none_clears_override_and_falls_back_to_default_path() { let temp_home = TempDir::new().expect("create temp home"); - let _env = EnvGuard::set_home(temp_home.path()); + let _env = TestEnvGuard::isolated(temp_home.path()); let override_dir = temp_home.path().join("custom-openclaw"); std::fs::create_dir_all(&override_dir).expect("create override dir"); diff --git a/src-tauri/src/cli/tui/runtime_systems/handlers.rs b/src-tauri/src/cli/tui/runtime_systems/handlers.rs index 89a67822..733f1736 100644 --- a/src-tauri/src/cli/tui/runtime_systems/handlers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/handlers.rs @@ -335,10 +335,10 @@ pub(crate) fn handle_managed_auth_msg(app: &mut App, msg: ManagedAuthMsg) { result, } => match result { Ok(Some(account)) => { - if !app + if app .managed_auth_login .as_ref() - .is_some_and(|login| login.device_code == device_code) + .is_none_or(|login| login.device_code != device_code) { return; } @@ -372,10 +372,10 @@ pub(crate) fn handle_managed_auth_msg(app: &mut App, msg: ManagedAuthMsg) { } Ok(None) => {} Err(err) => { - if !app + if app .managed_auth_login .as_ref() - .is_some_and(|login| login.device_code == device_code) + .is_none_or(|login| login.device_code != device_code) { return; } diff --git a/src-tauri/src/cli/tui/runtime_systems/workers.rs b/src-tauri/src/cli/tui/runtime_systems/workers.rs index a75c9937..34bc6012 100644 --- a/src-tauri/src/cli/tui/runtime_systems/workers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/workers.rs @@ -664,7 +664,6 @@ fn handle_session_req(req: SessionReq, tx: &mpsc::Sender) -> Result< crate::session_manager::scan_sessions_for_provider(&provider_id) }) .map_err(|_| "session scan panicked".to_string()); - let result = result; tx.send(SessionMsg::ScanFinished { request_id, result }) .map_err(|_| ()) } diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs index 1ae3cdd0..1c8b3090 100644 --- a/src-tauri/src/cli/tui/tests.rs +++ b/src-tauri/src/cli/tui/tests.rs @@ -1278,8 +1278,10 @@ fn usage_custom_range_app_switch_does_not_show_stale_custom_cache() { app.usage.range = data::UsageRangePreset::Custom(active_range); data.usage.begin_custom_range(active_range); - let mut stale_usage = data::UsageSnapshot::default(); - stale_usage.custom_range = Some(stale_range); + let mut stale_usage = data::UsageSnapshot { + custom_range: Some(stale_range), + ..Default::default() + }; stale_usage.summary_custom.total_requests = 99; stale_usage.summary_custom.total_cost_usd = 12.34; cache.usage_pricing_by_key.insert( @@ -1855,8 +1857,10 @@ fn update_check_finished_is_processed_when_request_id_matches() { title: texts::tui_update_checking_title().to_string(), message: texts::tui_loading().to_string(), }; - let mut update_check = RequestTracker::default(); - update_check.active = Some(7); + let mut update_check = RequestTracker { + active: Some(7), + ..Default::default() + }; let info = crate::cli::commands::update::UpdateCheckInfo { current_version: "4.7.0".to_string(), @@ -1894,8 +1898,10 @@ fn update_check_finished_for_homebrew_update_shows_brew_toast() { title: texts::tui_update_checking_title().to_string(), message: texts::tui_loading().to_string(), }; - let mut update_check = RequestTracker::default(); - update_check.active = Some(7); + let mut update_check = RequestTracker { + active: Some(7), + ..Default::default() + }; let info = crate::cli::commands::update::UpdateCheckInfo { current_version: "4.7.0".to_string(), @@ -1926,8 +1932,10 @@ fn update_check_finished_for_homebrew_update_shows_brew_toast() { fn update_check_finished_is_ignored_when_request_id_mismatch() { let mut app = App::new(None); app.overlay = Overlay::None; - let mut update_check = RequestTracker::default(); - update_check.active = Some(2); + let mut update_check = RequestTracker { + active: Some(2), + ..Default::default() + }; let stale = crate::cli::commands::update::UpdateCheckInfo { current_version: "4.7.0".to_string(), diff --git a/src-tauri/src/cli/tui/ui.rs b/src-tauri/src/cli/tui/ui.rs index ee618f99..65d2e66b 100644 --- a/src-tauri/src/cli/tui/ui.rs +++ b/src-tauri/src/cli/tui/ui.rs @@ -213,21 +213,6 @@ fn split_filter_area(area: Rect, app: &App) -> (Option, Rect) { (Some(chunks[0]), chunks[1]) } -#[cfg(test)] -mod effect_tests { - use super::*; - - #[test] - fn proxy_open_flash_uses_ping_pong_sine_in_out_once() { - let effect = proxy_open_flash_effect(Rect::new(0, 0, 80, 24)); - let dsl = effect.to_dsl().unwrap().to_string(); - - assert!(dsl.contains("fx::ping_pong("), "{dsl}"); - assert!(dsl.contains("SineInOut"), "{dsl}"); - assert!(!dsl.contains("fx::repeating("), "{dsl}"); - } -} - fn render_filter_bar(frame: &mut Frame<'_>, app: &App, area: Rect, theme: &super::theme::Theme) { let input = app.displayed_filter_input(); let outer = Block::default() @@ -269,3 +254,18 @@ fn render_filter_bar(frame: &mut Frame<'_>, app: &App, area: Rect, theme: &super frame.set_cursor_position((cursor_x, cursor_y)); } } + +#[cfg(test)] +mod effect_tests { + use super::*; + + #[test] + fn proxy_open_flash_uses_ping_pong_sine_in_out_once() { + let effect = proxy_open_flash_effect(Rect::new(0, 0, 80, 24)); + let dsl = effect.to_dsl().unwrap().to_string(); + + assert!(dsl.contains("fx::ping_pong("), "{dsl}"); + assert!(dsl.contains("SineInOut"), "{dsl}"); + assert!(!dsl.contains("fx::repeating("), "{dsl}"); + } +} diff --git a/src-tauri/src/cli/tui/ui/chrome.rs b/src-tauri/src/cli/tui/ui/chrome.rs index 483d5b3d..75cc6446 100644 --- a/src-tauri/src/cli/tui/ui/chrome.rs +++ b/src-tauri/src/cli/tui/ui/chrome.rs @@ -340,88 +340,86 @@ pub(super) fn render_footer( texts::tui_footer_filter_mode(), Style::default().fg(theme.dim), )] + } else if theme.no_color { + let proxy_segment = if proxy_action_available { + format!("P {} ", proxy_footer_label) + } else { + String::new() + }; + vec![Span::styled( + format!( + "{} {}{}", + texts::tui_footer_nav_keys(), + proxy_segment, + texts::tui_footer_action_keys_global(), + ), + Style::default(), + )] } else { - if theme.no_color { - let proxy_segment = if proxy_action_available { - format!("P {} ", proxy_footer_label) - } else { - String::new() - }; - vec![Span::styled( - format!( - "{} {}{}", - texts::tui_footer_nav_keys(), - proxy_segment, - texts::tui_footer_action_keys_global(), - ), - Style::default(), - )] + let nav_bg = super::theme::terminal_palette_color((101, 113, 160)); // #6571A0 + let act_bg = super::theme::terminal_palette_color((248, 248, 248)); // #F8F8F8 + let nav_fg = super::theme::terminal_palette_color((255, 255, 255)); + let act_fg = super::theme::terminal_palette_color((108, 108, 108)); + let nav_key_style = Style::default() + .fg(nav_fg) + .bg(nav_bg) + .add_modifier(Modifier::BOLD); + let nav_desc_style = Style::default().fg(nav_fg).bg(nav_bg); + let act_key_style = Style::default() + .fg(act_fg) + .bg(act_bg) + .add_modifier(Modifier::BOLD); + let act_desc_style = Style::default().fg(act_fg).bg(act_bg); + let nav_sep = Span::styled(" ", nav_desc_style); + let act_sep = Span::styled(" ", act_desc_style); + + let nav_items: &[(&str, &str)] = if i18n::is_chinese() { + &[("←→", "菜单/内容"), ("↑↓", "移动")] } else { - let nav_bg = super::theme::terminal_palette_color((101, 113, 160)); // #6571A0 - let act_bg = super::theme::terminal_palette_color((248, 248, 248)); // #F8F8F8 - let nav_fg = super::theme::terminal_palette_color((255, 255, 255)); - let act_fg = super::theme::terminal_palette_color((108, 108, 108)); - let nav_key_style = Style::default() - .fg(nav_fg) - .bg(nav_bg) - .add_modifier(Modifier::BOLD); - let nav_desc_style = Style::default().fg(nav_fg).bg(nav_bg); - let act_key_style = Style::default() - .fg(act_fg) - .bg(act_bg) - .add_modifier(Modifier::BOLD); - let act_desc_style = Style::default().fg(act_fg).bg(act_bg); - let nav_sep = Span::styled(" ", nav_desc_style); - let act_sep = Span::styled(" ", act_desc_style); - - let nav_items: &[(&str, &str)] = if i18n::is_chinese() { - &[("←→", "菜单/内容"), ("↑↓", "移动")] - } else { - &[("←→", "menu/content"), ("↑↓", "move")] - }; - - let act_items_base: &[(&str, &str)] = if i18n::is_chinese() { - &[ - ("[ ]", "切换应用"), - ("/", "过滤"), - ("Esc", "返回"), - ("?", "帮助"), - ] - } else { - &[ - ("[ ]", "switch app"), - ("/", "filter"), - ("Esc", "back"), - ("?", "help"), - ] - }; - - let mut act_items = act_items_base.to_vec(); - if proxy_action_available { - act_items.insert(0, ("P", proxy_footer_label)); - } + &[("←→", "menu/content"), ("↑↓", "move")] + }; + + let act_items_base: &[(&str, &str)] = if i18n::is_chinese() { + &[ + ("[ ]", "切换应用"), + ("/", "过滤"), + ("Esc", "返回"), + ("?", "帮助"), + ] + } else { + &[ + ("[ ]", "switch app"), + ("/", "filter"), + ("Esc", "back"), + ("?", "help"), + ] + }; + + let mut act_items = act_items_base.to_vec(); + if proxy_action_available { + act_items.insert(0, ("P", proxy_footer_label)); + } - let mut v = Vec::new(); - for (i, (key, desc)) in nav_items.iter().enumerate() { - if i > 0 { - v.push(nav_sep.clone()); - } - v.push(Span::styled(format!(" {} ", key), nav_key_style)); - v.push(Span::styled(format!(" {}", desc), nav_desc_style)); + let mut v = Vec::new(); + for (i, (key, desc)) in nav_items.iter().enumerate() { + if i > 0 { + v.push(nav_sep.clone()); } - v.push(Span::styled(" ", nav_desc_style)); - // gap between blocks - v.push(Span::raw(" ")); - for (i, (key, desc)) in act_items.iter().enumerate() { - if i > 0 { - v.push(act_sep.clone()); - } - v.push(Span::styled(format!(" {} ", key), act_key_style)); - v.push(Span::styled(format!(" {}", desc), act_desc_style)); + v.push(Span::styled(format!(" {} ", key), nav_key_style)); + v.push(Span::styled(format!(" {}", desc), nav_desc_style)); + } + v.push(Span::styled(" ", nav_desc_style)); + // gap between blocks + v.push(Span::raw(" ")); + for (i, (key, desc)) in act_items.iter().enumerate() { + if i > 0 { + v.push(act_sep.clone()); } - v.push(Span::styled(" ", act_desc_style)); - v + v.push(Span::styled(format!(" {} ", key), act_key_style)); + v.push(Span::styled(format!(" {}", desc), act_desc_style)); } + v.push(Span::styled(" ", act_desc_style)); + v }; frame.render_widget(Paragraph::new(Line::from(spans)), area); @@ -486,8 +484,7 @@ pub(super) fn toast_rect(content_area: Rect, message: &str) -> Rect { let max_width = content_area .width .saturating_sub(4) - .max(1) - .min(TOAST_MAX_WIDTH); + .clamp(1, TOAST_MAX_WIDTH); let min_width = TOAST_MIN_WIDTH.min(max_width); let content_width = message .lines() diff --git a/src-tauri/src/cli/tui/ui/config.rs b/src-tauri/src/cli/tui/ui/config.rs index 56efcf5b..51826290 100644 --- a/src-tauri/src/cli/tui/ui/config.rs +++ b/src-tauri/src/cli/tui/ui/config.rs @@ -515,7 +515,7 @@ fn render_openclaw_env_section_block( })) .split(inner); - for (row, chunk) in rows.iter().zip(chunks.into_iter()) { + for (row, chunk) in rows.iter().zip(chunks.iter()) { frame.render_widget( Paragraph::new(row.line.clone()).wrap(Wrap { trim: false }), *chunk, @@ -529,6 +529,10 @@ fn append_json_lines(lines: &mut Vec, value: &Value) { lines.extend(pretty.lines().map(|line| format!(" {line}"))); } +#[expect( + clippy::too_many_arguments, + reason = "OpenClaw route renderer receives UI context plus parsed config metadata" +)] fn render_openclaw_env_route( frame: &mut Frame<'_>, app: &App, @@ -854,7 +858,7 @@ fn render_openclaw_tools_section_block( })) .split(inner); - for (row, chunk) in rows.iter().zip(chunks.into_iter()) { + for (row, chunk) in rows.iter().zip(chunks.iter()) { let paragraph = if row.wrap { Paragraph::new(row.line.clone()).wrap(Wrap { trim: false }) } else { @@ -864,6 +868,10 @@ fn render_openclaw_tools_section_block( } } +#[expect( + clippy::too_many_arguments, + reason = "OpenClaw route renderer receives UI context plus parsed config metadata" +)] fn render_openclaw_tools_route( frame: &mut Frame<'_>, app: &App, @@ -1354,7 +1362,7 @@ fn render_openclaw_agents_section_block( })) .split(inner); - for (row, chunk) in rows.iter().zip(chunks.into_iter()) { + for (row, chunk) in rows.iter().zip(chunks.iter()) { let paragraph = if row.wrap { Paragraph::new(row.line.clone()).wrap(Wrap { trim: false }) } else { @@ -1364,6 +1372,10 @@ fn render_openclaw_agents_section_block( } } +#[expect( + clippy::too_many_arguments, + reason = "OpenClaw route renderer receives UI context plus parsed config metadata" +)] fn render_openclaw_agents_route( frame: &mut Frame<'_>, app: &App, diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index 268077c2..7fbc63df 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -1317,19 +1317,7 @@ pub(crate) fn provider_field_editor_line( }; if let Some(input) = provider.input(field) { - let shown = if matches!( - field, - ProviderAddField::ClaudeApiKey - | ProviderAddField::CodexApiKey - | ProviderAddField::GeminiApiKey - | ProviderAddField::OpenCodeApiKey - | ProviderAddField::HermesApiKey - ) { - input.value.clone() - } else { - input.value.clone() - }; - (Line::raw(shown), input.cursor) + (Line::raw(input.value.clone()), input.cursor) } else { let text = match field { ProviderAddField::ClaudeApiFormat => { diff --git a/src-tauri/src/cli/tui/ui/main_page.rs b/src-tauri/src/cli/tui/ui/main_page.rs index 58f8dfd0..da14502f 100644 --- a/src-tauri/src/cli/tui/ui/main_page.rs +++ b/src-tauri/src/cli/tui/ui/main_page.rs @@ -322,6 +322,10 @@ pub(super) fn render_main( ); } +#[expect( + clippy::too_many_arguments, + reason = "dashboard renderer receives precomputed proxy display metrics" +)] fn render_proxy_activity_dashboard( frame: &mut Frame<'_>, area: Rect, diff --git a/src-tauri/src/cli/tui/ui/overlay/basic.rs b/src-tauri/src/cli/tui/ui/overlay/basic.rs index d1832404..2735c794 100644 --- a/src-tauri/src/cli/tui/ui/overlay/basic.rs +++ b/src-tauri/src/cli/tui/ui/overlay/basic.rs @@ -379,6 +379,69 @@ pub(super) fn render_common_snippet_picker_overlay( frame.render_stateful_widget(list, body_area, &mut state); } +pub(super) fn render_provider_switch_live_conflicts_overlay( + frame: &mut Frame<'_>, + content_area: Rect, + theme: &theme::Theme, + conflicts: &[crate::services::provider::live_merge::ConfigConflict], +) { + let area = centered_rect_fixed(OVERLAY_FIXED_LG.0, OVERLAY_FIXED_LG.1, content_area); + frame.render_widget(Clear, area); + + let outer = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(overlay_border_style(theme, true)) + .title("Live configuration conflicts"); + frame.render_widget(outer.clone(), area); + let inner = outer.inner(area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner); + + render_key_bar_center( + frame, + chunks[0], + theme, + &[ + ("L", "Keep local"), + ("C", "Use cc-switch"), + ("Esc", texts::tui_key_cancel()), + ], + ); + + let body_area = inset_top(chunks[1], 1); + let mut lines = vec![ + Line::raw("Choose which values should be written before cc-switch saves this switch."), + Line::raw(""), + ]; + let max_conflicts = body_area.height.saturating_sub(4) as usize / 6; + for conflict in conflicts.iter().take(max_conflicts.max(1)) { + lines.push(Line::styled( + format!( + "{} {} {}", + conflict.app_type.as_str(), + conflict.target, + conflict.path + ), + Style::default().fg(theme.accent), + )); + lines.push(Line::raw(format!("local: {}", conflict.local))); + lines.push(Line::raw(format!("cc-switch: {}", conflict.incoming))); + lines.push(Line::raw("")); + } + if conflicts.len() > max_conflicts.max(1) { + lines.push(Line::raw(format!( + "... and {} more conflict(s)", + conflicts.len() - max_conflicts.max(1) + ))); + } + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), body_area); +} + fn render_scrolling_lines(frame: &mut Frame<'_>, area: Rect, lines: &[String], scroll: usize) { let height = area.height as usize; let start = scroll.min(lines.len()); diff --git a/src-tauri/src/cli/tui/ui/overlay/layout.rs b/src-tauri/src/cli/tui/ui/overlay/layout.rs index e751f10d..757485d9 100644 --- a/src-tauri/src/cli/tui/ui/overlay/layout.rs +++ b/src-tauri/src/cli/tui/ui/overlay/layout.rs @@ -26,8 +26,7 @@ pub(crate) fn compact_lines_overlay_rect( let max_width = content_area .width .saturating_sub(4) - .max(1) - .min(TOAST_MAX_WIDTH); + .clamp(1, TOAST_MAX_WIDTH); let min_width = 36.min(max_width); let content_width = lines .iter() diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index c5321ab3..163ca711 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -212,7 +212,7 @@ pub(super) fn render_claude_api_format_picker_overlay( }); let choices = crate::cli::tui::form::ClaudeApiFormat::choices_for_app(&app_type); - let items = choices.into_iter().copied().map(|api_format| { + let items = choices.iter().copied().map(|api_format| { let marker = if api_format == current { texts::tui_marker_active() } else { @@ -741,6 +741,10 @@ fn render_hermes_model_picker_input( } } +#[expect( + clippy::too_many_arguments, + reason = "model picker renderer receives transient search state" +)] pub(super) fn render_model_fetch_picker_overlay( frame: &mut Frame<'_>, content_area: Rect, @@ -1432,6 +1436,10 @@ pub(super) fn render_skills_sync_method_picker_overlay( frame.render_stateful_widget(list, body_area, &mut state); } +#[expect( + clippy::too_many_arguments, + reason = "app picker renderer receives list state and display labels" +)] fn render_apps_picker_overlay( frame: &mut Frame<'_>, content_area: Rect, diff --git a/src-tauri/src/cli/tui/ui/overlay/render.rs b/src-tauri/src/cli/tui/ui/overlay/render.rs index 69dd6365..91eb0fd3 100644 --- a/src-tauri/src/cli/tui/ui/overlay/render.rs +++ b/src-tauri/src/cli/tui/ui/overlay/render.rs @@ -50,6 +50,14 @@ pub(crate) fn render_overlay( provider_id, *selected, ), + Overlay::ProviderSwitchLiveConflicts { conflicts, .. } => { + super::basic::render_provider_switch_live_conflicts_overlay( + frame, + content_area, + theme, + conflicts, + ) + } Overlay::FailoverQueueManager { selected } => { super::pickers::render_failover_queue_manager_overlay( frame, diff --git a/src-tauri/src/cli/tui/ui/overlay/status.rs b/src-tauri/src/cli/tui/ui/overlay/status.rs index bdc68987..2cf9ff11 100644 --- a/src-tauri/src/cli/tui/ui/overlay/status.rs +++ b/src-tauri/src/cli/tui/ui/overlay/status.rs @@ -214,7 +214,7 @@ pub(super) fn render_update_downloading_overlay( let progress_text = if let Some(t) = total { if t > 0 { - let pct = ((downloaded.saturating_mul(100) / t).min(100)) as u64; + let pct = (downloaded.saturating_mul(100) / t).min(100); texts::tui_update_downloading_progress(pct, downloaded / 1024, t / 1024) } else { texts::tui_update_downloading_kb(downloaded / 1024) diff --git a/src-tauri/src/cli/tui/ui/pricing.rs b/src-tauri/src/cli/tui/ui/pricing.rs index 3aadbedd..fa32765e 100644 --- a/src-tauri/src/cli/tui/ui/pricing.rs +++ b/src-tauri/src/cli/tui/ui/pricing.rs @@ -224,7 +224,7 @@ fn format_price_per_million(value: &str) -> String { return "-".to_string(); } match trimmed.parse::() { - Ok(value) if value == 0.0 => "$0".to_string(), + Ok(0.0) => "$0".to_string(), Ok(value) if value >= 100.0 => format!("${value:.0}"), Ok(value) if value >= 10.0 => format!("${value:.1}"), Ok(value) if value >= 1.0 => format!("${value:.2}"), diff --git a/src-tauri/src/cli/tui/ui/shared.rs b/src-tauri/src/cli/tui/ui/shared.rs index 6842530c..918af329 100644 --- a/src-tauri/src/cli/tui/ui/shared.rs +++ b/src-tauri/src/cli/tui/ui/shared.rs @@ -247,9 +247,7 @@ pub(super) fn quota_compact_line( theme: &super::theme::Theme, quiet_missing: bool, ) -> Option> { - let Some(state) = state else { - return None; - }; + let state = state?; if state.loading && state.quota.is_none() { return Some(Line::from(Span::styled( diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index 1fb1ca6c..00f4ae51 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -1375,16 +1375,12 @@ fn cell_column_of(buf: &Buffer, y: u16, needle: &str) -> Option { return Some(0); } - for x in 0..buf.area.width { - if cells.iter().enumerate().all(|(offset, symbol)| { + (0..buf.area.width).find(|&x| { + cells.iter().enumerate().all(|(offset, symbol)| { let cell_x = x.saturating_add(offset as u16); cell_x < buf.area.width && buf[(cell_x, y)].symbol() == symbol - }) { - return Some(x); - } - } - - None + }) + }) } fn all_text(buf: &Buffer) -> String { diff --git a/src-tauri/src/cli/tui/ui/usage.rs b/src-tauri/src/cli/tui/ui/usage.rs index c0fa0f0a..1e8709b3 100644 --- a/src-tauri/src/cli/tui/ui/usage.rs +++ b/src-tauri/src/cli/tui/ui/usage.rs @@ -1073,7 +1073,7 @@ fn render_usage_detail_body( detail_line(usage_text("Request", "请求"), &row.request_id, theme), detail_line( usage_text("Time", "时间"), - &format_log_time(row.created_at, true), + format_log_time(row.created_at, true), theme, ), detail_line(usage_text("App", "应用"), &row.app_type, theme), @@ -1091,42 +1091,42 @@ fn render_usage_detail_body( ), detail_line( usage_text("Status", "状态"), - &status_label(row.status_code), + status_label(row.status_code), theme, ), detail_line( usage_text("Tokens", "Token"), - &format!("{}", row.total_tokens()), + format!("{}", row.total_tokens()), theme, ), detail_line( usage_text("Input", "输入"), - &row.input_tokens.to_string(), + row.input_tokens.to_string(), theme, ), detail_line( usage_text("Output", "输出"), - &row.output_tokens.to_string(), + row.output_tokens.to_string(), theme, ), detail_line( usage_text("Cache Read", "缓存读取"), - &row.cache_read_tokens.to_string(), + row.cache_read_tokens.to_string(), theme, ), detail_line( usage_text("Cache Create", "缓存创建"), - &row.cache_creation_tokens.to_string(), + row.cache_creation_tokens.to_string(), theme, ), detail_line( usage_text("Cost", "费用"), - &format_money(row.total_cost_usd), + format_money(row.total_cost_usd), theme, ), detail_line( usage_text("Latency", "延迟"), - &format!("{}ms", row.latency_ms), + format!("{}ms", row.latency_ms), theme, ), detail_line(usage_text("First Token", "首字"), &first_token, theme), @@ -1320,7 +1320,7 @@ fn format_metric_value(value: f64, metric: UsageMetric) -> String { } } -fn fit_trend_points<'a>(trend: &'a [UsageTrendBucket], width: u16) -> Vec<&'a UsageTrendBucket> { +fn fit_trend_points(trend: &[UsageTrendBucket], width: u16) -> Vec<&UsageTrendBucket> { let point_budget = if width < 44 { width.saturating_sub(4).max(6) as usize } else { diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 2c098e8b..340e6211 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -551,21 +551,50 @@ fn set_codex_model_catalog_json_field( Ok(doc.to_string()) } -pub fn prepare_codex_config_text_with_model_catalog( +#[derive(Clone, Debug)] +pub struct PreparedCodexConfigText { + pub config_text: String, + pub model_catalog: Option, +} + +pub fn prepare_codex_config_text_with_model_catalog_payload( settings: &Value, config_text: &str, -) -> Result { +) -> Result { let catalog_path = get_codex_model_catalog_path(); if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text)? { let config_text = set_codex_model_catalog_json_field(config_text, Some(&catalog_path))?; - write_json_file(&catalog_path, &catalog)?; - Ok(config_text) + Ok(PreparedCodexConfigText { + config_text, + model_catalog: Some(catalog), + }) } else { - set_codex_model_catalog_json_field(config_text, None) + Ok(PreparedCodexConfigText { + config_text: set_codex_model_catalog_json_field(config_text, None)?, + model_catalog: None, + }) } } +pub fn write_prepared_codex_model_catalog( + prepared: &PreparedCodexConfigText, +) -> Result<(), AppError> { + if let Some(catalog) = &prepared.model_catalog { + write_json_file(&get_codex_model_catalog_path(), catalog)?; + } + Ok(()) +} + +pub fn prepare_codex_config_text_with_model_catalog( + settings: &Value, + config_text: &str, +) -> Result { + let prepared = prepare_codex_config_text_with_model_catalog_payload(settings, config_text)?; + write_prepared_codex_model_catalog(&prepared)?; + Ok(prepared.config_text) +} + pub fn read_codex_model_catalog_simplified_from_live() -> Result, AppError> { let config_text = read_codex_config_text()?; let generated_path = get_codex_model_catalog_path(); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index f71c90f0..1f2e03fe 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1349,6 +1349,7 @@ mod tests { std::fs::OpenOptions::new() .write(true) .create(true) + .truncate(true) .mode(0o600) .open(&db_path) .expect("create db file"); @@ -1397,6 +1398,7 @@ mod tests { std::fs::OpenOptions::new() .write(true) .create(true) + .truncate(true) .mode(0o644) .open(&db_path) .expect("create db file"); @@ -1427,6 +1429,7 @@ mod tests { std::fs::OpenOptions::new() .write(true) .create(true) + .truncate(true) .mode(0o644) .open(&db_path) .expect("create db file"); @@ -1484,6 +1487,7 @@ mod tests { std::fs::OpenOptions::new() .write(true) .create(true) + .truncate(true) .mode(0o644) .open(path) .expect("create sensitive file"); @@ -1519,6 +1523,7 @@ mod tests { std::fs::OpenOptions::new() .write(true) .create(true) + .truncate(true) .mode(0o644) .open(&plugin_json) .expect("create skill metadata"); @@ -1571,6 +1576,7 @@ mod tests { std::fs::OpenOptions::new() .write(true) .create(true) + .truncate(true) .mode(0o644) .open(&json_path) .expect("create json file"); @@ -1651,6 +1657,7 @@ mod tests { std::fs::OpenOptions::new() .write(true) .create(true) + .truncate(true) .mode(0o644) .open(&db_path) .expect("create db file"); diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 87609244..d1d47d96 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -12,6 +12,7 @@ use std::fs; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; @@ -20,6 +21,7 @@ const SYNC_IMPORT_RESTORE_TABLES: &[&str] = &[ "proxy_request_logs", "stream_check_logs", "proxy_live_backup", + "proxy_failover_live_snapshots", "usage_daily_rollups", ]; @@ -180,7 +182,7 @@ impl Database { let backup = Backup::new(&temp_conn, &mut main_conn) .map_err(|e| AppError::Database(e.to_string()))?; backup - .step(-1) + .run_to_completion(5, Duration::from_millis(25), None) .map_err(|e| AppError::Database(e.to_string()))?; } @@ -214,7 +216,7 @@ impl Database { let backup = Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?; backup - .step(-1) + .run_to_completion(5, Duration::from_millis(25), None) .map_err(|e| AppError::Database(e.to_string()))?; } @@ -493,7 +495,7 @@ impl Database { let backup = Backup::new(&conn, &mut dest_conn) .map_err(|e| AppError::Database(e.to_string()))?; backup - .step(-1) + .run_to_completion(5, Duration::from_millis(25), None) .map_err(|e| AppError::Database(e.to_string()))?; } backup_path diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 3cb3e134..c9bb9431 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -1030,6 +1030,122 @@ impl Database { Ok(()) } + // ==================== Failover Live Snapshots ==================== + + pub async fn save_failover_live_snapshot( + &self, + app_type: &str, + provider_id: &str, + config_json: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + "INSERT OR REPLACE INTO proxy_failover_live_snapshots + (app_type, provider_id, config_json, generated_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![app_type, provider_id, config_json, now], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + pub async fn get_failover_live_snapshot( + &self, + app_type: &str, + provider_id: &str, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let result = conn.query_row( + "SELECT app_type, provider_id, config_json, generated_at + FROM proxy_failover_live_snapshots + WHERE app_type = ?1 AND provider_id = ?2", + rusqlite::params![app_type, provider_id], + |row| { + Ok(FailoverLiveSnapshot { + app_type: row.get(0)?, + provider_id: row.get(1)?, + config_json: row.get(2)?, + generated_at: row.get(3)?, + }) + }, + ); + + match result { + Ok(snapshot) => Ok(Some(snapshot)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + pub async fn list_failover_live_snapshots( + &self, + app_type: &str, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT app_type, provider_id, config_json, generated_at + FROM proxy_failover_live_snapshots + WHERE app_type = ?1 + ORDER BY provider_id", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let rows = stmt + .query_map(rusqlite::params![app_type], |row| { + Ok(FailoverLiveSnapshot { + app_type: row.get(0)?, + provider_id: row.get(1)?, + config_json: row.get(2)?, + generated_at: row.get(3)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut snapshots = Vec::new(); + for row in rows { + snapshots.push(row.map_err(|e| AppError::Database(e.to_string()))?); + } + Ok(snapshots) + } + + pub async fn delete_failover_live_snapshot( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM proxy_failover_live_snapshots WHERE app_type = ?1 AND provider_id = ?2", + rusqlite::params![app_type, provider_id], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn delete_failover_live_snapshots_for_app( + &self, + app_type: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM proxy_failover_live_snapshots WHERE app_type = ?1", + rusqlite::params![app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn delete_all_failover_live_snapshots(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM proxy_failover_live_snapshots", []) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + // ==================== Sync Methods for Tray Menu ==================== /// 同步获取应用的 proxy 启用状态和自动故障转移状态 diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index e4126297..62b8be29 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -231,6 +231,20 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 17. Proxy Failover Live Snapshots 表 (按供应商生成的故障转移 Live 配置) + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_failover_live_snapshots ( + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + config_json TEXT NOT NULL, + generated_at TEXT NOT NULL, + PRIMARY KEY (app_type, provider_id), + FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + conn.execute( "CREATE TABLE IF NOT EXISTS usage_daily_rollups ( date TEXT NOT NULL, @@ -408,7 +422,7 @@ impl Database { } 10 => { log::info!( - "迁移数据库从 v10 到 v11(usage_daily_rollups 保留请求/计价模型维度)" + "迁移数据库从 v10 到 v11(usage_daily_rollups 保留请求/计价模型维度 + 故障转移 Live 配置快照)" ); Self::migrate_v10_to_v11(conn)?; Self::set_user_version(conn, 11)?; @@ -807,27 +821,41 @@ impl Database { return Ok(()); } - let changed = conn - .execute( - "UPDATE proxy_config - SET auto_failover_enabled = 0 + let stale_without_takeover: i64 = conn + .query_row( + "SELECT COUNT(*) + FROM proxy_config WHERE enabled = 0 AND auto_failover_enabled != 0", [], + |row| row.get(0), ) - .map_err(|e| AppError::Database(format!("清理无代理接管的故障转移状态失败: {e}")))?; + .map_err(|e| AppError::Database(format!("检查无代理接管的故障转移状态失败: {e}")))?; - if changed > 0 { - log::warn!("已清理 {changed} 个未启用代理接管的故障转移状态"); + if stale_without_takeover > 0 { + let changed = conn + .execute( + "UPDATE proxy_config + SET auto_failover_enabled = 0 + WHERE enabled = 0 AND auto_failover_enabled != 0", + [], + ) + .map_err(|e| { + AppError::Database(format!("清理无代理接管的故障转移状态失败: {e}")) + })?; + + if changed > 0 { + log::warn!("已清理 {changed} 个未启用代理接管的故障转移状态"); + } } if Self::table_exists(conn, "providers")? && Self::has_column(conn, "providers", "app_type")? && Self::has_column(conn, "providers", "in_failover_queue")? { - let changed = conn - .execute( - "UPDATE proxy_config - SET auto_failover_enabled = 0 + let stale_empty_queue: i64 = conn + .query_row( + "SELECT COUNT(*) + FROM proxy_config WHERE auto_failover_enabled != 0 AND NOT EXISTS ( SELECT 1 FROM providers @@ -835,11 +863,28 @@ impl Database { AND providers.in_failover_queue = 1 )", [], + |row| row.get(0), ) - .map_err(|e| AppError::Database(format!("清理空队列故障转移状态失败: {e}")))?; - - if changed > 0 { - log::warn!("已清理 {changed} 个空故障转移队列的自动故障转移状态"); + .map_err(|e| AppError::Database(format!("检查空队列故障转移状态失败: {e}")))?; + + if stale_empty_queue > 0 { + let changed = conn + .execute( + "UPDATE proxy_config + SET auto_failover_enabled = 0 + WHERE auto_failover_enabled != 0 + AND NOT EXISTS ( + SELECT 1 FROM providers + WHERE providers.app_type = proxy_config.app_type + AND providers.in_failover_queue = 1 + )", + [], + ) + .map_err(|e| AppError::Database(format!("清理空队列故障转移状态失败: {e}")))?; + + if changed > 0 { + log::warn!("已清理 {changed} 个空故障转移队列的自动故障转移状态"); + } } } @@ -1227,7 +1272,7 @@ impl Database { Ok(()) } - /// v10 -> v11 迁移:保留请求模型与计价模型维度。 + /// v10 -> v11 迁移:保留请求/计价模型维度并添加故障转移 Live 配置快照。 /// /// WebDAV 同步会在不同分支之间交换 SQLite 快照;上游 v11 已经把 /// `usage_daily_rollups` 的主键扩展为 `(model, request_model, pricing_model)`。 @@ -1238,54 +1283,67 @@ impl Database { Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?; } - if !Self::table_exists(conn, "usage_daily_rollups")? { - log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建"); - return Ok(()); - } + if Self::table_exists(conn, "usage_daily_rollups")? { + if Self::has_column(conn, "usage_daily_rollups", "request_model")? + && Self::has_column(conn, "usage_daily_rollups", "pricing_model")? + { + log::info!("v10 -> v11:usage_daily_rollups 已包含 v11 维度,跳过重建"); + } else { + conn.execute_batch( + "ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10; + CREATE TABLE usage_daily_rollups ( + date TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', + request_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + avg_latency_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) + ); + INSERT INTO usage_daily_rollups + (date, app_type, provider_id, model, request_model, pricing_model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms) + SELECT date, app_type, provider_id, model, '', '', + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + FROM usage_daily_rollups_v10; + DROP TABLE usage_daily_rollups_v10;", + ) + .map_err(|e| { + AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}")) + })?; - if Self::has_column(conn, "usage_daily_rollups", "request_model")? - && Self::has_column(conn, "usage_daily_rollups", "pricing_model")? - { - log::info!("v10 -> v11:usage_daily_rollups 已包含 v11 维度,跳过重建"); - return Ok(()); + log::info!( + "v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度" + ); + } + } else { + log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建"); } - conn.execute_batch( - "ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10; - CREATE TABLE usage_daily_rollups ( - date TEXT NOT NULL, - app_type TEXT NOT NULL, - provider_id TEXT NOT NULL, - model TEXT NOT NULL, - request_model TEXT NOT NULL DEFAULT '', - pricing_model TEXT NOT NULL DEFAULT '', - request_count INTEGER NOT NULL DEFAULT 0, - success_count INTEGER NOT NULL DEFAULT 0, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_creation_tokens INTEGER NOT NULL DEFAULT 0, - total_cost_usd TEXT NOT NULL DEFAULT '0', - avg_latency_ms INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) - ); - INSERT INTO usage_daily_rollups - (date, app_type, provider_id, model, request_model, pricing_model, - request_count, success_count, input_tokens, output_tokens, - cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms) - SELECT date, app_type, provider_id, model, '', '', - request_count, success_count, input_tokens, output_tokens, - cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms - FROM usage_daily_rollups_v10; - DROP TABLE usage_daily_rollups_v10;", + conn.execute( + "CREATE TABLE IF NOT EXISTS proxy_failover_live_snapshots ( + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + config_json TEXT NOT NULL, + generated_at TEXT NOT NULL, + PRIMARY KEY (app_type, provider_id), + FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE + )", + [], ) - .map_err(|e| { - AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}")) - })?; + .map_err(|e| AppError::Database(format!("创建故障转移 Live 配置快照表失败: {e}")))?; - log::info!( - "v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度" - ); + log::info!("v10 -> v11 迁移完成:已添加故障转移 Live 配置快照表"); Ok(()) } diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index eff1e382..5c510d71 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -261,6 +261,8 @@ fn init_rejects_unsafe_config_dir() { "unexpected error: {err}" ); } +#[test] +#[serial_test::serial] fn readonly_snapshot_rejects_missing_database_without_creating_file() { let _lock = crate::test_support::lock_test_home_and_settings(); let temp = tempfile::tempdir().expect("create temp dir"); @@ -1703,6 +1705,66 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() { assert!(pricing_rows > 0, "model_pricing should be seeded"); } +#[test] +fn schema_migration_v10_adds_failover_live_snapshots() { + let conn = Connection::open_in_memory().expect("open memory db"); + conn.execute_batch( + r#" + CREATE TABLE providers ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + settings_config TEXT NOT NULL, + meta TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY (id, app_type) + ); + CREATE TABLE mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + enabled_hermes BOOLEAN NOT NULL DEFAULT 0 + ); + CREATE TABLE skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + directory TEXT NOT NULL, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + enabled_hermes BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY); + CREATE TABLE proxy_live_backup ( + app_type TEXT PRIMARY KEY, + original_config TEXT NOT NULL, + backed_up_at TEXT NOT NULL + ); + "#, + ) + .expect("seed v10 schema"); + Database::set_user_version(&conn, 10).expect("set user_version=10"); + + Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); + + assert!( + Database::table_exists(&conn, "proxy_failover_live_snapshots").expect("check table"), + "proxy_failover_live_snapshots should exist after v10 migration" + ); + assert_eq!( + Database::get_user_version(&conn).expect("version after migration"), + SCHEMA_VERSION + ); +} + #[test] fn schema_dry_run_does_not_write_to_disk() { // Create minimal valid config for migration @@ -2223,6 +2285,7 @@ fn init_does_not_silently_fix_existing_dir_permissions() { "init should not silently change existing dir permissions" ); } +#[test] fn model_pricing_delete_survives_reseed_until_user_upserts() { let db = Database::memory().expect("create memory db"); diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 905bda6f..b6e4a067 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -33,6 +33,7 @@ use crate::config::{atomic_write, create_managed_config_dir_all, get_app_config_dir, home_dir}; use crate::error::AppError; +use crate::services::provider::live_merge; use crate::settings::{effective_backup_retain_count, get_hermes_override_dir}; use chrono::Local; use indexmap::IndexMap; @@ -665,8 +666,27 @@ pub fn get_provider(name: &str) -> Result, AppError> { /// on disk but not submitted by the UI are preserved. /// - Holds the write lock end-to-end to avoid TOCTOU races. pub fn set_provider(name: &str, provider_config: Value) -> Result { - let _guard = hermes_write_lock().lock()?; + set_provider_with_resolution( + name, + provider_config, + live_merge::ConflictPolicy::PreferIncoming.into(), + ) +} + +pub fn set_provider_with_resolution( + name: &str, + provider_config: Value, + resolution: live_merge::ConflictResolution<'_>, +) -> Result { + let providers_value = prepare_provider_with_resolution(name, provider_config, resolution)?; + write_prepared_providers(&providers_value) +} +pub fn prepare_provider_with_resolution( + name: &str, + provider_config: Value, + resolution: live_merge::ConflictResolution<'_>, +) -> Result { let config = read_hermes_config()?; ensure_provider_writable(&config, name, "edit")?; @@ -686,19 +706,13 @@ pub fn set_provider(name: &str, provider_config: Value) -> Result Result Result { + write_yaml_section_to_config("custom_providers", providers_value) } /// Remove a provider from the `custom_providers:` list. @@ -804,10 +824,8 @@ pub fn get_current_provider_id() -> Result, AppError> { .map(str::trim) .unwrap_or_default(); - if !provider_ref.is_empty() { - if get_providers()?.contains_key(provider_ref) { - return Ok(Some(provider_ref.to_string())); - } + if !provider_ref.is_empty() && get_providers()?.contains_key(provider_ref) { + return Ok(Some(provider_ref.to_string())); } if provider_ref == "custom" { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a4bd8c56..b6a575f0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -113,10 +113,10 @@ fn initialize_startup_state_if_needed(command: &Option) -> Result<(), } fn database_access_required(command: &Option) -> bool { - match command { - Some(Commands::Completions(_)) | Some(Commands::Update(_)) => false, - _ => true, - } + !matches!( + command, + Some(Commands::Completions(_)) | Some(Commands::Update(_)) + ) } #[cfg(test)] diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 07825bba..a768ba5f 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -1,6 +1,7 @@ use crate::config::{atomic_write, create_managed_config_dir_all, get_app_config_dir, home_dir}; use crate::error::AppError; use crate::provider::OpenClawProviderConfig; +use crate::services::provider::live_merge; use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir}; use chrono::Local; use indexmap::IndexMap; @@ -233,7 +234,7 @@ impl OpenClawConfigDocument { if let Some(existing) = key_value_pairs .iter_mut() - .find(|pair| json5_key_name(&pair.key).as_deref() == Some(key)) + .find(|pair| json5_key_name(&pair.key) == Some(key)) { existing.value = new_value; return Ok(()); @@ -720,6 +721,7 @@ pub fn get_provider(id: &str) -> Result, AppError> { Ok(get_providers()?.get(id).cloned()) } +#[allow(dead_code)] pub fn set_provider(id: &str, provider_config: Value) -> Result { let mut full_config = read_openclaw_config()?; { @@ -745,6 +747,58 @@ pub fn set_provider(id: &str, provider_config: Value) -> Result, +) -> Result { + let models_value = prepare_provider_with_resolution(id, provider_config, resolution)?; + write_prepared_models(&models_value) +} + +pub fn prepare_provider_with_resolution( + id: &str, + provider_config: Value, + resolution: live_merge::ConflictResolution<'_>, +) -> Result { + let mut full_config = read_openclaw_config()?; + { + let root = ensure_object(&mut full_config); + let models = root.entry("models".to_string()).or_insert_with(|| { + json!({ + "mode": "merge", + "providers": {} + }) + }); + let providers = ensure_object(models) + .entry("providers".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let providers = ensure_object(providers); + let merged = match providers.get(id) { + Some(existing) => live_merge::merge_json_live( + &crate::app_config::AppType::OpenClaw, + format!("openclaw.json models.providers.{id}"), + existing.clone(), + &provider_config, + resolution, + )?, + None => provider_config, + }; + providers.insert(id.to_string(), merged); + } + + Ok(full_config.get("models").cloned().unwrap_or_else(|| { + json!({ + "mode": "merge", + "providers": {} + }) + })) +} + +pub fn write_prepared_models(models_value: &Value) -> Result { + write_root_section("models", models_value) +} + pub fn remove_provider(id: &str) -> Result { let mut config = read_openclaw_config()?; let mut removed = false; @@ -815,6 +869,7 @@ pub fn get_typed_providers() -> Result, Ok(result) } +#[allow(dead_code)] pub fn set_typed_provider( id: &str, config: &OpenClawProviderConfig, @@ -824,6 +879,30 @@ pub fn set_typed_provider( set_provider(id, value) } +#[expect( + dead_code, + reason = "kept for direct typed OpenClaw provider writes with conflict resolution" +)] +pub fn set_typed_provider_with_resolution( + id: &str, + config: &OpenClawProviderConfig, + resolution: live_merge::ConflictResolution<'_>, +) -> Result { + let value = + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?; + set_provider_with_resolution(id, value, resolution) +} + +pub fn prepare_typed_provider_with_resolution( + id: &str, + config: &OpenClawProviderConfig, + resolution: live_merge::ConflictResolution<'_>, +) -> Result { + let value = + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?; + prepare_provider_with_resolution(id, value, resolution) +} + #[allow(dead_code)] pub fn get_model_catalog() -> Result>, AppError> { let config = read_openclaw_config()?; @@ -962,8 +1041,10 @@ mod tests { impl SettingsGuard { fn with_openclaw_dir(path: &std::path::Path) -> Self { let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.openclaw_config_dir = Some(path.display().to_string()); + let settings = AppSettings { + openclaw_config_dir: Some(path.display().to_string()), + ..Default::default() + }; update_settings(settings).expect("set openclaw override dir"); Self { previous } } @@ -1577,6 +1658,7 @@ mod tests { #[test] #[serial(home_settings)] + #[allow(clippy::needless_update)] fn backup_cleanup_uses_settings_retain_count() { let _guard = lock_test_home_and_settings(); let dir = tempdir().expect("create tempdir"); @@ -1585,9 +1667,11 @@ mod tests { let _home = HomeGuard::set(dir.path()); let previous = get_settings(); - let mut settings = AppSettings::default(); - settings.openclaw_config_dir = Some(openclaw_dir.display().to_string()); - settings.backup_retain_count = Some(2); + let settings = AppSettings { + openclaw_config_dir: Some(openclaw_dir.display().to_string()), + backup_retain_count: Some(2), + ..Default::default() + }; update_settings(settings).expect("set settings with backup retain count"); fs::write( diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index 5b731ff4..6d32a149 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -1,6 +1,7 @@ use crate::config::write_json_file; use crate::error::AppError; use crate::provider::OpenCodeProviderConfig; +use crate::services::provider::live_merge; use crate::settings::get_opencode_override_dir; use indexmap::IndexMap; use serde_json::{json, Map, Value}; @@ -79,6 +80,50 @@ pub fn set_provider(id: &str, provider: Value) -> Result<(), AppError> { write_opencode_config(&full_config) } +pub fn set_provider_with_resolution( + id: &str, + provider: Value, + resolution: live_merge::ConflictResolution<'_>, +) -> Result<(), AppError> { + let full_config = prepare_provider_with_resolution(id, provider, resolution)?; + write_opencode_config(&full_config) +} + +pub fn prepare_provider_with_resolution( + id: &str, + provider: Value, + resolution: live_merge::ConflictResolution<'_>, +) -> Result { + let mut full_config = read_opencode_config()?; + + if full_config.get("provider").is_none() { + full_config["provider"] = json!({}); + } + + if let Some(providers) = full_config + .get_mut("provider") + .and_then(Value::as_object_mut) + { + let merged = match providers.get(id) { + Some(existing) => live_merge::merge_json_live( + &crate::app_config::AppType::OpenCode, + format!("opencode.json provider.{id}"), + existing.clone(), + &provider, + resolution, + )?, + None => provider, + }; + providers.insert(id.to_string(), merged); + } + + Ok(full_config) +} + +pub fn write_prepared_config(config: &Value) -> Result<(), AppError> { + write_opencode_config(config) +} + pub fn remove_provider(id: &str) -> Result<(), AppError> { let mut full_config = read_opencode_config()?; if let Some(providers) = full_config @@ -105,12 +150,37 @@ pub fn get_typed_providers() -> Result, Ok(result) } +#[expect(dead_code, reason = "kept for direct typed OpenCode provider writes")] pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> { let value = serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?; set_provider(id, value) } +#[expect( + dead_code, + reason = "kept for direct typed OpenCode provider writes with conflict resolution" +)] +pub fn set_typed_provider_with_resolution( + id: &str, + config: &OpenCodeProviderConfig, + resolution: live_merge::ConflictResolution<'_>, +) -> Result<(), AppError> { + let value = + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?; + set_provider_with_resolution(id, value, resolution) +} + +pub fn prepare_typed_provider_with_resolution( + id: &str, + config: &OpenCodeProviderConfig, + resolution: live_merge::ConflictResolution<'_>, +) -> Result { + let value = + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?; + prepare_provider_with_resolution(id, value, resolution) +} + pub fn get_mcp_servers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index fc06d7c4..05898b46 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -151,6 +151,10 @@ impl RequestForwarder { } #[cfg(test)] + #[expect( + clippy::too_many_arguments, + reason = "test helper mirrors proxy forwarding inputs" + )] pub async fn forward_response( &self, app_type: &AppType, @@ -174,6 +178,10 @@ impl RequestForwarder { .map_err(|failure| failure.error) } + #[expect( + clippy::too_many_arguments, + reason = "forwarding requires request, provider, and retry options" + )] pub async fn forward_response_detailed( &self, app_type: &AppType, @@ -388,6 +396,11 @@ impl RequestForwarder { } } + #[allow(dead_code)] + #[expect( + clippy::too_many_arguments, + reason = "forwarding requires request, provider, and retry options" + )] pub async fn forward_buffered_response( &self, app_type: &AppType, @@ -411,6 +424,10 @@ impl RequestForwarder { .map_err(|failure| failure.error) } + #[expect( + clippy::too_many_arguments, + reason = "forwarding requires request, provider, and retry options" + )] pub async fn forward_buffered_response_detailed( &self, app_type: &AppType, @@ -625,6 +642,10 @@ impl RequestForwarder { } } + #[expect( + clippy::too_many_arguments, + reason = "request execution needs provider, endpoint, headers, and retry options" + )] async fn send_streaming_request( &self, app_type: &AppType, @@ -685,7 +706,6 @@ impl RequestForwarder { tokio::time::timeout(remaining_timeout, request.send()) .await .map_err(|_| ()) - .map(|result| result) } None => Ok(request.send().await), } { @@ -774,6 +794,10 @@ impl RequestForwarder { } } + #[expect( + clippy::too_many_arguments, + reason = "request execution needs provider, endpoint, headers, and retry options" + )] async fn send_buffered_request( &self, app_type: &AppType, @@ -834,7 +858,6 @@ impl RequestForwarder { tokio::time::timeout(remaining_timeout, request.send()) .await .map_err(|_| ()) - .map(|result| result) } None => Ok(request.send().await), } { diff --git a/src-tauri/src/proxy/forwarder/request_builder.rs b/src-tauri/src/proxy/forwarder/request_builder.rs index 3694cab7..ddb2d654 100644 --- a/src-tauri/src/proxy/forwarder/request_builder.rs +++ b/src-tauri/src/proxy/forwarder/request_builder.rs @@ -387,6 +387,10 @@ fn copilot_optimizer_session_id(body: &Value, headers: &HeaderMap) -> String { .unwrap_or_default() } +#[expect( + clippy::too_many_arguments, + reason = "request construction needs provider, endpoint, headers, and format flags" +)] async fn build_request( client: &reqwest::Client, adapter: &dyn ProviderAdapter, @@ -405,27 +409,21 @@ async fn build_request( copilot_optimization: Option<&CopilotOptimization>, ) -> Result { let (endpoint_path, endpoint_query) = split_endpoint_and_query(endpoint); - let url = if claude_api_format == Some("gemini_native") { - let is_full_url = provider - .meta - .as_ref() - .and_then(|meta| meta.is_full_url) - .unwrap_or(false); - super::super::gemini_url::resolve_gemini_native_url(base_url, endpoint, is_full_url) - } else if provider + let base_url_trimmed = base_url.trim_end_matches('/'); + let is_full_url = provider .meta .as_ref() .and_then(|meta| meta.is_full_url) - .unwrap_or(false) - { - append_query_to_url(base_url.trim_end_matches('/'), endpoint_query) - } else if base_url - .trim_end_matches('/') - .to_ascii_lowercase() - .ends_with("/chat/completions") - && endpoint_path.trim_matches('/') == "chat/completions" + .unwrap_or(false); + let url = if claude_api_format == Some("gemini_native") { + super::super::gemini_url::resolve_gemini_native_url(base_url, endpoint, is_full_url) + } else if is_full_url + || (base_url_trimmed + .to_ascii_lowercase() + .ends_with("/chat/completions") + && endpoint_path.trim_matches('/') == "chat/completions") { - append_query_to_url(base_url.trim_end_matches('/'), endpoint_query) + append_query_to_url(base_url_trimmed, endpoint_query) } else if codex_responses_to_chat { append_endpoint_to_base_url(base_url, endpoint) } else { diff --git a/src-tauri/src/proxy/forwarder/tests/request_building.rs b/src-tauri/src/proxy/forwarder/tests/request_building.rs index be90a149..6ccace07 100644 --- a/src-tauri/src/proxy/forwarder/tests/request_building.rs +++ b/src-tauri/src/proxy/forwarder/tests/request_building.rs @@ -1,3 +1,8 @@ +#![expect( + clippy::await_holding_lock, + reason = "tests serialize global auth/config state while exercising async request preparation" +)] + use std::sync::atomic::Ordering; use std::time::Duration; diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 3eabc6c5..e5fa03e9 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -9,7 +9,6 @@ use crate::provider::Provider; use super::{ error::ProxyError, provider_router::ProviderRouter, - providers::gemini_shadow::GeminiShadowStore, server::ProxyServerState, session::extract_session_id, types::{AppProxyConfig, CopilotOptimizerConfig, OptimizerConfig, RectifierConfig}, @@ -138,6 +137,7 @@ mod tests { use tempfile::TempDir; use tokio::sync::RwLock; + use crate::proxy::providers::gemini_shadow::GeminiShadowStore; use crate::{database::Database, proxy::types::ProxyConfig}; struct TempHome { diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index fd7af934..9554e66d 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -1376,7 +1376,7 @@ mod tests { .expect("converted SSE should yield before upstream EOF") .expect("stream should yield a chunk") .expect("stream chunk should be ok"); - output.push_str(&String::from_utf8(chunk.to_vec()).expect("SSE should be UTF-8")); + output.push_str(core::str::from_utf8(&chunk).expect("SSE should be UTF-8")); if output.contains("event: response.output_text.delta") { break; diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 5eb7050a..865c39aa 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -66,11 +66,9 @@ impl ProviderRouter { circuit_open_count += 1; } } - } else { - if let Some(current) = self.current_provider(app_type)? { - total_providers = 1; - result.push(current); - } + } else if let Some(current) = self.current_provider(app_type)? { + total_providers = 1; + result.push(current); } if result.is_empty() { diff --git a/src-tauri/src/proxy/response.rs b/src-tauri/src/proxy/response.rs index 03a53167..e76c0f2d 100644 --- a/src-tauri/src/proxy/response.rs +++ b/src-tauri/src/proxy/response.rs @@ -302,6 +302,10 @@ where build_buffered_json_response_inner(status, headers, body, transform) } +#[expect( + clippy::too_many_arguments, + reason = "stream response setup needs timeout, format, and shadow context" +)] pub fn build_anthropic_stream_response( response: reqwest::Response, first_byte_timeout: Option, diff --git a/src-tauri/src/proxy/response_handler/tests.rs b/src-tauri/src/proxy/response_handler/tests.rs index eb3b7977..d50aeea7 100644 --- a/src-tauri/src/proxy/response_handler/tests.rs +++ b/src-tauri/src/proxy/response_handler/tests.rs @@ -313,6 +313,20 @@ async fn streaming_success_syncs_failover_state_after_body_drains() { backup_snapshot .get("base_url") .and_then(serde_json::Value::as_str), + Some("https://current.example") + ); + + let snapshot = db + .get_failover_live_snapshot("claude", "claude-failover") + .await + .expect("read failover snapshot") + .expect("failover snapshot should exist"); + let snapshot_value: serde_json::Value = + serde_json::from_str(&snapshot.config_json).expect("parse failover snapshot"); + assert_eq!( + snapshot_value + .get("base_url") + .and_then(serde_json::Value::as_str), Some("https://failover.example") ); } diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 6c941612..69c6e04f 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -128,7 +128,7 @@ impl ProxyServerState { if takeover_enabled { ProxyService::new(self.db.clone()) - .update_live_backup_from_provider(app_type.as_str(), provider) + .refresh_failover_live_snapshot_for_provider(app_type.as_str(), provider) .await .ok(); } @@ -175,6 +175,199 @@ impl ProxyServerState { } } +fn update_success_rate(status: &mut ProxyStatus) { + status.success_rate = if status.total_requests == 0 { + 0.0 + } else { + (status.success_requests as f32 / status.total_requests as f32) * 100.0 + }; +} + +pub struct ProxyServer { + state: ProxyServerState, + shutdown_tx: Arc>>>, + server_handle: Arc>>>, +} + +impl ProxyServer { + pub fn new(config: ProxyConfig, db: Arc) -> Self { + let provider_router = Arc::new(ProviderRouter::new(db.clone())); + let managed_session_token = std::env::var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY) + .ok() + .filter(|value| !value.trim().is_empty()); + let status = ProxyStatus { + managed_session_token, + ..ProxyStatus::default() + }; + + Self { + state: ProxyServerState { + db, + config: Arc::new(RwLock::new(config)), + status: Arc::new(RwLock::new(status)), + start_time: Arc::new(RwLock::new(None)), + current_providers: Arc::new(RwLock::new(HashMap::new())), + provider_router, + codex_chat_history: Arc::new(CodexChatHistoryStore::default()), + gemini_shadow: Arc::new(GeminiShadowStore::default()), + }, + shutdown_tx: Arc::new(RwLock::new(None)), + server_handle: Arc::new(RwLock::new(None)), + } + } + + pub async fn start(&self) -> Result { + if self.shutdown_tx.read().await.is_some() { + let status = self.get_status().await; + return Ok(ProxyServerInfo { + address: status.address, + port: status.port, + started_at: chrono::Utc::now().to_rfc3339(), + }); + } + + let bind_config = self.state.config.read().await.clone(); + let addr: SocketAddr = + format!("{}:{}", bind_config.listen_address, bind_config.listen_port) + .parse() + .map_err(|e| format!("invalid bind address: {e}"))?; + + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| format!("bind proxy listener failed: {e}"))?; + let local_addr = listener + .local_addr() + .map_err(|e| format!("read proxy listener address failed: {e}"))?; + + super::http_client::set_proxy_port(local_addr.port()); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + *self.shutdown_tx.write().await = Some(shutdown_tx); + + { + let mut status = self.state.status.write().await; + status.running = true; + status.address = bind_config.listen_address.clone(); + status.port = local_addr.port(); + } + *self.state.start_time.write().await = Some(Instant::now()); + + let app = self.build_router(); + let state = self.state.clone(); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + + state.status.write().await.running = false; + *state.start_time.write().await = None; + }); + *self.server_handle.write().await = Some(handle); + + Ok(ProxyServerInfo { + address: bind_config.listen_address, + port: local_addr.port(), + started_at: chrono::Utc::now().to_rfc3339(), + }) + } + + pub async fn set_active_target(&self, app_type: &str, provider_id: &str, provider_name: &str) { + let mut current_providers = self.state.current_providers.write().await; + current_providers.insert( + app_type.to_string(), + (provider_id.to_string(), provider_name.to_string()), + ); + } + + pub async fn stop(&self) -> Result<(), String> { + if let Some(tx) = self.shutdown_tx.write().await.take() { + let _ = tx.send(()); + } else { + return Ok(()); + } + + if let Some(handle) = self.server_handle.write().await.take() { + handle + .await + .map_err(|e| format!("join proxy task failed: {e}"))?; + } + Ok(()) + } + + pub async fn get_status(&self) -> ProxyStatus { + self.state.snapshot_status().await + } + + pub async fn update_circuit_breaker_configs(&self, config: CircuitBreakerConfig) { + self.state.provider_router.update_all_configs(config).await; + } + + pub async fn reset_provider_circuit_breaker(&self, provider_id: &str, app_type: &str) { + self.state + .provider_router + .reset_provider_breaker(provider_id, app_type) + .await; + } + + #[cfg(test)] + pub(crate) fn provider_router(&self) -> Arc { + self.state.provider_router.clone() + } + + fn build_router(&self) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + Router::new() + .route("/health", get(handlers::health_check)) + .route("/status", get(handlers::get_status)) + .route("/v1/messages", post(handlers::handle_messages)) + .route("/claude/v1/messages", post(handlers::handle_messages)) + .route("/chat/completions", post(handlers::handle_chat_completions)) + .route( + "/v1/chat/completions", + post(handlers::handle_chat_completions), + ) + .route( + "/v1/v1/chat/completions", + post(handlers::handle_chat_completions), + ) + .route( + "/codex/v1/chat/completions", + post(handlers::handle_chat_completions), + ) + .route("/responses", post(handlers::handle_responses)) + .route("/v1/responses", post(handlers::handle_responses)) + .route("/v1/v1/responses", post(handlers::handle_responses)) + .route("/codex/v1/responses", post(handlers::handle_responses)) + .route( + "/responses/compact", + post(handlers::handle_responses_compact), + ) + .route( + "/v1/responses/compact", + post(handlers::handle_responses_compact), + ) + .route( + "/v1/v1/responses/compact", + post(handlers::handle_responses_compact), + ) + .route( + "/codex/v1/responses/compact", + post(handlers::handle_responses_compact), + ) + .route("/v1beta/*path", post(handlers::handle_gemini)) + .route("/gemini/v1beta/*path", post(handlers::handle_gemini)) + .layer(DefaultBodyLimit::max(200 * 1024 * 1024)) + .layer(cors) + .with_state(self.state.clone()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -354,6 +547,20 @@ mod tests { backup_snapshot .get("base_url") .and_then(serde_json::Value::as_str), + Some("https://current.example") + ); + + let snapshot = db + .get_failover_live_snapshot("claude", "claude-failover") + .await + .expect("read failover snapshot after sync") + .expect("failover snapshot should exist"); + let snapshot_value: serde_json::Value = + serde_json::from_str(&snapshot.config_json).expect("parse failover snapshot"); + assert_eq!( + snapshot_value + .get("base_url") + .and_then(serde_json::Value::as_str), Some("https://failover.example") ); @@ -473,196 +680,3 @@ mod tests { assert_eq!(status.active_targets[0].provider_id, "claude-failover"); } } - -fn update_success_rate(status: &mut ProxyStatus) { - status.success_rate = if status.total_requests == 0 { - 0.0 - } else { - (status.success_requests as f32 / status.total_requests as f32) * 100.0 - }; -} - -pub struct ProxyServer { - state: ProxyServerState, - shutdown_tx: Arc>>>, - server_handle: Arc>>>, -} - -impl ProxyServer { - pub fn new(config: ProxyConfig, db: Arc) -> Self { - let provider_router = Arc::new(ProviderRouter::new(db.clone())); - let managed_session_token = std::env::var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY) - .ok() - .filter(|value| !value.trim().is_empty()); - let status = ProxyStatus { - managed_session_token, - ..ProxyStatus::default() - }; - - Self { - state: ProxyServerState { - db, - config: Arc::new(RwLock::new(config)), - status: Arc::new(RwLock::new(status)), - start_time: Arc::new(RwLock::new(None)), - current_providers: Arc::new(RwLock::new(HashMap::new())), - provider_router, - codex_chat_history: Arc::new(CodexChatHistoryStore::default()), - gemini_shadow: Arc::new(GeminiShadowStore::default()), - }, - shutdown_tx: Arc::new(RwLock::new(None)), - server_handle: Arc::new(RwLock::new(None)), - } - } - - pub async fn start(&self) -> Result { - if self.shutdown_tx.read().await.is_some() { - let status = self.get_status().await; - return Ok(ProxyServerInfo { - address: status.address, - port: status.port, - started_at: chrono::Utc::now().to_rfc3339(), - }); - } - - let bind_config = self.state.config.read().await.clone(); - let addr: SocketAddr = - format!("{}:{}", bind_config.listen_address, bind_config.listen_port) - .parse() - .map_err(|e| format!("invalid bind address: {e}"))?; - - let listener = tokio::net::TcpListener::bind(addr) - .await - .map_err(|e| format!("bind proxy listener failed: {e}"))?; - let local_addr = listener - .local_addr() - .map_err(|e| format!("read proxy listener address failed: {e}"))?; - - super::http_client::set_proxy_port(local_addr.port()); - - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - *self.shutdown_tx.write().await = Some(shutdown_tx); - - { - let mut status = self.state.status.write().await; - status.running = true; - status.address = bind_config.listen_address.clone(); - status.port = local_addr.port(); - } - *self.state.start_time.write().await = Some(Instant::now()); - - let app = self.build_router(); - let state = self.state.clone(); - let handle = tokio::spawn(async move { - let _ = axum::serve(listener, app) - .with_graceful_shutdown(async { - let _ = shutdown_rx.await; - }) - .await; - - state.status.write().await.running = false; - *state.start_time.write().await = None; - }); - *self.server_handle.write().await = Some(handle); - - Ok(ProxyServerInfo { - address: bind_config.listen_address, - port: local_addr.port(), - started_at: chrono::Utc::now().to_rfc3339(), - }) - } - - pub async fn set_active_target(&self, app_type: &str, provider_id: &str, provider_name: &str) { - let mut current_providers = self.state.current_providers.write().await; - current_providers.insert( - app_type.to_string(), - (provider_id.to_string(), provider_name.to_string()), - ); - } - - pub async fn stop(&self) -> Result<(), String> { - if let Some(tx) = self.shutdown_tx.write().await.take() { - let _ = tx.send(()); - } else { - return Ok(()); - } - - if let Some(handle) = self.server_handle.write().await.take() { - handle - .await - .map_err(|e| format!("join proxy task failed: {e}"))?; - } - Ok(()) - } - - pub async fn get_status(&self) -> ProxyStatus { - self.state.snapshot_status().await - } - - pub async fn update_circuit_breaker_configs(&self, config: CircuitBreakerConfig) { - self.state.provider_router.update_all_configs(config).await; - } - - pub async fn reset_provider_circuit_breaker(&self, provider_id: &str, app_type: &str) { - self.state - .provider_router - .reset_provider_breaker(provider_id, app_type) - .await; - } - - #[cfg(test)] - pub(crate) fn provider_router(&self) -> Arc { - self.state.provider_router.clone() - } - - fn build_router(&self) -> Router { - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); - - Router::new() - .route("/health", get(handlers::health_check)) - .route("/status", get(handlers::get_status)) - .route("/v1/messages", post(handlers::handle_messages)) - .route("/claude/v1/messages", post(handlers::handle_messages)) - .route("/chat/completions", post(handlers::handle_chat_completions)) - .route( - "/v1/chat/completions", - post(handlers::handle_chat_completions), - ) - .route( - "/v1/v1/chat/completions", - post(handlers::handle_chat_completions), - ) - .route( - "/codex/v1/chat/completions", - post(handlers::handle_chat_completions), - ) - .route("/responses", post(handlers::handle_responses)) - .route("/v1/responses", post(handlers::handle_responses)) - .route("/v1/v1/responses", post(handlers::handle_responses)) - .route("/codex/v1/responses", post(handlers::handle_responses)) - .route( - "/responses/compact", - post(handlers::handle_responses_compact), - ) - .route( - "/v1/responses/compact", - post(handlers::handle_responses_compact), - ) - .route( - "/v1/v1/responses/compact", - post(handlers::handle_responses_compact), - ) - .route( - "/codex/v1/responses/compact", - post(handlers::handle_responses_compact), - ) - .route("/v1beta/*path", post(handlers::handle_gemini)) - .route("/gemini/v1beta/*path", post(handlers::handle_gemini)) - .layer(DefaultBodyLimit::max(200 * 1024 * 1024)) - .layer(cors) - .with_state(self.state.clone()) - } -} diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index a0deed15..19da6211 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -173,6 +173,15 @@ pub struct LiveBackup { pub backed_up_at: String, } +/// 故障转移队列按供应商生成的 Live 配置快照 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FailoverLiveSnapshot { + pub app_type: String, + pub provider_id: String, + pub config_json: String, + pub generated_at: String, +} + /// 全局代理配置(统一字段,三行镜像) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index cf2f1982..7e46b4a5 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -54,6 +54,10 @@ pub fn parse_response_usage(app_type: &AppType, body: &[u8]) -> Option Option { let value: Value = serde_json::from_slice(body).ok()?; parse_response_value(&AppType::Claude, &value) @@ -157,6 +161,7 @@ impl StreamLogCollector { } } + #[expect(dead_code, reason = "kept for Claude-only stream collector callers")] pub fn parsed_usage(&self) -> Option { parse_claude_stream_usage(&self.events) } diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index 1d8ae155..6e28a81b 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -183,6 +183,10 @@ mod tests { use crate::test_support::lock_test_home_and_settings; #[tokio::test] + #[expect( + clippy::await_holding_lock, + reason = "test serializes global auth manager state" + )] async fn auth_status_marks_default_account() { let _lock = lock_test_home_and_settings(); let _manager = CodexOAuthService::test_manager_with_account( diff --git a/src-tauri/src/services/codex_oauth.rs b/src-tauri/src/services/codex_oauth.rs index 9af51385..eaed339f 100644 --- a/src-tauri/src/services/codex_oauth.rs +++ b/src-tauri/src/services/codex_oauth.rs @@ -8,8 +8,10 @@ use crate::proxy::providers::codex_oauth_auth::{ }; use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota}; -fn manager_store() -> &'static RwLock)>> { - static STORE: OnceLock)>>> = OnceLock::new(); +type CodexOAuthManagerStore = RwLock)>>; + +fn manager_store() -> &'static CodexOAuthManagerStore { + static STORE: OnceLock = OnceLock::new(); STORE.get_or_init(|| RwLock::new(None)) } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 21cf8e8c..ed029221 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -5,7 +5,6 @@ use crate::error::AppError; use crate::provider::Provider; use crate::store::AppState; use chrono::Utc; -use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; @@ -309,47 +308,24 @@ impl ConfigService { provider, common_config_snippet.as_deref(), ); - let effective = ProviderService::build_effective_live_snapshot( - &AppType::Codex, + ProviderService::write_codex_live_force( provider, common_config_snippet.as_deref(), apply_common_config, )?; - let settings = effective.as_object().ok_or_else(|| { - AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象")) - })?; - let auth = settings.get("auth").ok_or_else(|| { - AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段")) - })?; - if !auth.is_object() { - return Err(AppError::Config(format!( - "供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象" - ))); - } - let cfg_text = settings.get("config").and_then(Value::as_str); - - let category = ProviderService::codex_live_write_category(provider); - if category == Some("official") { - crate::codex_config::write_codex_provider_live_with_catalog( - &provider.settings_config, - category, - auth, - cfg_text, - )?; - } else { - crate::codex_config::write_codex_provider_live_config_only_with_catalog( - &provider.settings_config, - auth, - cfg_text, - )?; - } crate::mcp::sync_enabled_to_codex(config)?; + let auth_path = crate::codex_config::get_codex_auth_path(); + let auth_after = if auth_path.exists() { + crate::config::read_json_file(&auth_path)? + } else { + serde_json::json!({}) + }; let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; if let Some(manager) = config.get_manager_mut(&AppType::Codex) { if let Some(target) = manager.providers.get_mut(provider_id) { let mut restored = serde_json::json!({ - "auth": auth.clone(), + "auth": auth_after, "config": cfg_text_after, }); let restore_provider_token = @@ -379,29 +355,20 @@ impl ConfigService { provider_id: &str, provider: &Provider, ) -> Result<(), AppError> { - use crate::config::{read_json_file, write_json_file}; - - let settings_path = crate::config::get_claude_settings_path(); - if let Some(parent) = settings_path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - let common_config_snippet = config.common_config_snippets.claude.clone(); let apply_common_config = ProviderService::provider_uses_common_config_for_app( &AppType::Claude, provider, common_config_snippet.as_deref(), ); - let effective = ProviderService::build_effective_live_snapshot( - &AppType::Claude, + ProviderService::write_claude_live_force( provider, common_config_snippet.as_deref(), apply_common_config, )?; - write_json_file(&settings_path, &effective)?; - - let live_after = read_json_file::(&settings_path)?; + let settings_path = crate::config::get_claude_settings_path(); + let live_after = crate::config::read_json_file::(&settings_path)?; if let Some(manager) = config.get_manager_mut(&AppType::Claude) { if let Some(target) = manager.providers.get_mut(provider_id) { target.settings_config = ProviderService::normalize_settings_config_for_storage( diff --git a/src-tauri/src/services/copilot_auth.rs b/src-tauri/src/services/copilot_auth.rs index 01ca5c3e..3da481da 100644 --- a/src-tauri/src/services/copilot_auth.rs +++ b/src-tauri/src/services/copilot_auth.rs @@ -7,8 +7,10 @@ use crate::proxy::providers::copilot_auth::{ GitHubAccount, GitHubDeviceCodeResponse, }; -fn manager_store() -> &'static RwLock)>> { - static STORE: OnceLock)>>> = OnceLock::new(); +type CopilotAuthManagerStore = RwLock)>>; + +fn manager_store() -> &'static CopilotAuthManagerStore { + static STORE: OnceLock = OnceLock::new(); STORE.get_or_init(|| RwLock::new(None)) } @@ -180,6 +182,10 @@ impl CopilotAuthService { } #[cfg(test)] + #[expect( + dead_code, + reason = "kept for tests that need seeded Copilot auth state" + )] pub(crate) async fn seed_account_for_tests( account_id: &str, github_token: &str, diff --git a/src-tauri/src/services/env_manager.rs b/src-tauri/src/services/env_manager.rs index 1a8ea491..f702e514 100644 --- a/src-tauri/src/services/env_manager.rs +++ b/src-tauri/src/services/env_manager.rs @@ -1,5 +1,8 @@ use super::env_checker::EnvConflict; -use crate::config::{create_managed_config_dir_all, get_app_config_dir, write_json_file}; +use crate::config::{ + create_managed_config_dir_all, get_app_config_dir, restrict_dir_permissions, + restrict_file_permissions, write_json_file, +}; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::fs; @@ -45,6 +48,7 @@ fn create_backup(conflicts: &[EnvConflict]) -> Result { // Get backup directory let backup_dir = get_backup_dir()?; create_managed_config_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?; + restrict_dir_permissions(&backup_dir).map_err(|e| format!("设置备份目录权限失败: {e}"))?; // Generate backup file name with timestamp let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); @@ -58,6 +62,7 @@ fn create_backup(conflicts: &[EnvConflict]) -> Result { }; write_json_file(&backup_file, &backup_info).map_err(|e| format!("写入备份文件失败: {e}"))?; + restrict_file_permissions(&backup_file).map_err(|e| format!("设置备份文件权限失败: {e}"))?; Ok(backup_info) } @@ -236,6 +241,7 @@ mod tests { #[cfg(unix)] #[test] + #[serial_test::serial] fn env_backup_json_is_written_owner_only() { use std::os::unix::fs::PermissionsExt; diff --git a/src-tauri/src/services/provider/claude.rs b/src-tauri/src/services/provider/claude.rs index 67065769..03b71049 100644 --- a/src-tauri/src/services/provider/claude.rs +++ b/src-tauri/src/services/provider/claude.rs @@ -214,13 +214,68 @@ impl ProviderService { Ok(()) } + #[expect( + dead_code, + reason = "kept for direct Claude live writes without custom resolution" + )] pub(super) fn write_claude_live( provider: &Provider, common_config_snippet: Option<&str>, apply_common_config: bool, ) -> Result<(), AppError> { - if !crate::sync_policy::should_sync_live(&AppType::Claude) { - return Ok(()); + Self::write_claude_live_with_resolution( + provider, + common_config_snippet, + None, + apply_common_config, + live_merge::ConflictPolicy::Fail.into(), + ) + } + + pub(super) fn write_claude_live_with_resolution( + provider: &Provider, + common_config_snippet: Option<&str>, + previous_common_config_snippet: Option<&str>, + apply_common_config: bool, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result<(), AppError> { + let prepared = Self::prepare_claude_live_write( + provider, + common_config_snippet, + previous_common_config_snippet, + apply_common_config, + false, + resolution, + )?; + Self::apply_claude_live_write(&prepared) + } + + pub(crate) fn write_claude_live_force( + provider: &Provider, + common_config_snippet: Option<&str>, + apply_common_config: bool, + ) -> Result<(), AppError> { + let prepared = Self::prepare_claude_live_write( + provider, + common_config_snippet, + None, + apply_common_config, + true, + live_merge::ConflictPolicy::Fail.into(), + )?; + Self::apply_claude_live_write(&prepared) + } + + pub(super) fn prepare_claude_live_write( + provider: &Provider, + common_config_snippet: Option<&str>, + previous_common_config_snippet: Option<&str>, + apply_common_config: bool, + force_sync: bool, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { + if !force_sync && !crate::sync_policy::should_sync_live(&AppType::Claude) { + return Ok(PreparedLiveWrite::Noop); } let settings_path = get_claude_settings_path(); @@ -230,8 +285,43 @@ impl ProviderService { common_config_snippet, apply_common_config, )?; + let local = if settings_path.exists() { + let local = read_json_file::(&settings_path)?; + let local = + common_config::strip_common_config_snippet_from_live_settings_or_provider_snapshot( + &AppType::Claude, + provider, + local, + previous_common_config_snippet, + ); + if apply_common_config { + local + } else { + common_config::strip_common_config_snippet_from_live_settings_or_provider_snapshot( + &AppType::Claude, + provider, + local, + common_config_snippet, + ) + } + } else { + json!({}) + }; + let settings = live_merge::merge_json_live( + &AppType::Claude, + "settings.json", + local, + &content_to_write, + resolution, + )?; - write_json_file(&settings_path, &content_to_write)?; - Ok(()) + Ok(PreparedLiveWrite::Claude { settings }) + } + + pub(super) fn apply_claude_live_write(prepared: &PreparedLiveWrite) -> Result<(), AppError> { + let PreparedLiveWrite::Claude { settings } = prepared else { + return Ok(()); + }; + write_json_file(&get_claude_settings_path(), settings) } } diff --git a/src-tauri/src/services/provider/codex.rs b/src-tauri/src/services/provider/codex.rs index 85bc7345..49276510 100644 --- a/src-tauri/src/services/provider/codex.rs +++ b/src-tauri/src/services/provider/codex.rs @@ -421,13 +421,68 @@ impl ProviderService { /// Aligned with upstream: the stored `settings_config.config` is the full config.toml text. /// We write it directly to `~/.codex/config.toml`, optionally merging the common config snippet. /// Auth is handled separately via auth.json. + #[expect( + dead_code, + reason = "kept for direct Codex live writes without custom resolution" + )] pub(super) fn write_codex_live( provider: &Provider, common_config_snippet: Option<&str>, apply_common_config: bool, ) -> Result<(), AppError> { - if !crate::sync_policy::should_sync_live(&AppType::Codex) { - return Ok(()); + Self::write_codex_live_with_resolution( + provider, + common_config_snippet, + None, + apply_common_config, + live_merge::ConflictPolicy::Fail.into(), + ) + } + + pub(super) fn write_codex_live_with_resolution( + provider: &Provider, + common_config_snippet: Option<&str>, + previous_common_config_snippet: Option<&str>, + apply_common_config: bool, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result<(), AppError> { + let prepared = Self::prepare_codex_live_write( + provider, + common_config_snippet, + previous_common_config_snippet, + apply_common_config, + false, + resolution, + )?; + Self::apply_codex_live_write(&prepared) + } + + pub(crate) fn write_codex_live_force( + provider: &Provider, + common_config_snippet: Option<&str>, + apply_common_config: bool, + ) -> Result<(), AppError> { + let prepared = Self::prepare_codex_live_write( + provider, + common_config_snippet, + None, + apply_common_config, + true, + live_merge::ConflictPolicy::PreferIncoming.into(), + )?; + Self::apply_codex_live_write(&prepared) + } + + pub(super) fn prepare_codex_live_write( + provider: &Provider, + common_config_snippet: Option<&str>, + previous_common_config_snippet: Option<&str>, + apply_common_config: bool, + force_sync: bool, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { + if !force_sync && !crate::sync_policy::should_sync_live(&AppType::Codex) { + return Ok(PreparedLiveWrite::Noop); } let effective = Self::build_effective_live_snapshot( @@ -450,13 +505,148 @@ impl ProviderService { AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) })?; - crate::codex_config::write_codex_provider_live_with_catalog( - &provider.settings_config, - Self::codex_live_write_category(provider), - auth, - Some(cfg_text), + let prepared_config = + crate::codex_config::prepare_codex_config_text_with_model_catalog_payload( + &provider.settings_config, + cfg_text, + )?; + let category = Self::codex_live_write_category(provider); + let should_write_auth = category == Some("official") + || (!force_sync && !crate::settings::preserve_codex_official_auth_on_switch()); + let incoming_config = if should_write_auth { + prepared_config.config_text.clone() + } else { + crate::codex_config::prepare_codex_provider_live_config( + auth, + &prepared_config.config_text, + )? + }; + + let local_config = { + let local_config = crate::codex_config::read_codex_config_text()?; + let local_settings = json!({ "config": local_config }); + let local_settings = + common_config::strip_common_config_snippet_from_live_settings_or_provider_snapshot( + &AppType::Codex, + provider, + local_settings, + previous_common_config_snippet, + ); + let should_strip_current_snippet = !apply_common_config + && previous_common_config_snippet == common_config_snippet + && common_config_snippet.is_some(); + let local_settings = if should_strip_current_snippet { + common_config::strip_common_config_snippet_from_live_settings_or_provider_snapshot( + &AppType::Codex, + provider, + local_settings, + common_config_snippet, + ) + } else { + local_settings + }; + local_settings + .get("config") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + let merged_config = live_merge::merge_toml_live( + &AppType::Codex, + "config.toml", + &local_config, + &incoming_config, + resolution, )?; + let auth = if should_write_auth { + let auth_path = get_codex_auth_path(); + if crate::codex_config::codex_auth_has_login_material(auth) { + let local_auth = if auth_path.exists() { + read_json_file::(&auth_path)? + } else { + json!({}) + }; + PreparedCodexAuthWrite::Write(live_merge::merge_json_live( + &AppType::Codex, + "auth.json", + local_auth, + auth, + resolution, + )?) + } else if auth_path.exists() { + let local_auth = read_json_file::(&auth_path)?; + if local_auth == *auth { + if auth.is_null() || auth.as_object().is_some_and(serde_json::Map::is_empty) { + PreparedCodexAuthWrite::Delete + } else { + PreparedCodexAuthWrite::Write(local_auth) + } + } else { + let local = serde_json::to_string(&local_auth) + .unwrap_or_else(|_| local_auth.to_string()); + let incoming = serde_json::to_string(auth).unwrap_or_else(|_| auth.to_string()); + let conflict = live_merge::ConfigConflict { + app_type: AppType::Codex, + target: "auth.json".to_string(), + path: "".to_string(), + local, + incoming, + }; + match live_merge::resolve_conflict_choice(conflict, resolution)? { + live_merge::ConflictChoice::KeepLocal => { + PreparedCodexAuthWrite::Write(local_auth) + } + live_merge::ConflictChoice::UseIncoming => { + if auth.is_null() + || auth.as_object().is_some_and(serde_json::Map::is_empty) + { + PreparedCodexAuthWrite::Delete + } else { + PreparedCodexAuthWrite::Write(auth.clone()) + } + } + } + } + } else if auth.is_null() || auth.as_object().is_some_and(serde_json::Map::is_empty) { + PreparedCodexAuthWrite::Delete + } else { + PreparedCodexAuthWrite::Write(auth.clone()) + } + } else { + PreparedCodexAuthWrite::Preserve + }; + + Ok(PreparedLiveWrite::Codex { + auth, + config: crate::codex_config::PreparedCodexConfigText { + config_text: merged_config, + model_catalog: prepared_config.model_catalog, + }, + }) + } + + pub(super) fn apply_codex_live_write(prepared: &PreparedLiveWrite) -> Result<(), AppError> { + let PreparedLiveWrite::Codex { auth, config } = prepared else { + return Ok(()); + }; + + crate::codex_config::write_prepared_codex_model_catalog(config)?; + match auth { + PreparedCodexAuthWrite::Preserve => { + crate::codex_config::write_codex_live_config_atomic(Some(&config.config_text))? + } + PreparedCodexAuthWrite::Write(auth) => { + crate::codex_config::write_codex_live_atomic(auth, Some(&config.config_text))? + } + PreparedCodexAuthWrite::Delete => { + crate::codex_config::write_codex_live_atomic_optional_auth( + None, + Some(&config.config_text), + )? + } + } + Ok(()) } } diff --git a/src-tauri/src/services/provider/codex_openai_auth_tests.rs b/src-tauri/src/services/provider/codex_openai_auth_tests.rs index c37a2911..e75a0a7b 100644 --- a/src-tauri/src/services/provider/codex_openai_auth_tests.rs +++ b/src-tauri/src/services/provider/codex_openai_auth_tests.rs @@ -4,6 +4,10 @@ use tempfile::TempDir; use crate::test_support::TestEnvGuard; +fn prefer_incoming_conflicts() -> live_merge::ConflictResolution<'static> { + live_merge::ConflictPolicy::PreferIncoming.into() +} + #[test] #[serial] fn switch_codex_provider_writes_stored_config_directly() { @@ -33,7 +37,13 @@ fn switch_codex_provider_writes_stored_config_directly() { } let state = state_from_config(config); - ProviderService::switch(&state, AppType::Codex, "p1").expect("switch should succeed"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p1", + prefer_incoming_conflicts(), + ) + .expect("switch should succeed"); let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read codex config.toml"); @@ -89,7 +99,13 @@ fn switch_codex_provider_migrates_legacy_flat_config() { .insert("custom1".to_string(), provider); let state = state_from_config(config); - ProviderService::switch(&state, AppType::Codex, "custom1").expect("switch should succeed"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "custom1", + prefer_incoming_conflicts(), + ) + .expect("switch should succeed"); let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read codex config.toml"); diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index 42b3fd08..897b6a52 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -549,14 +549,64 @@ pub(super) fn strip_common_config_from_live_settings( live_settings: Value, snippet: Option<&str>, ) -> Value { - if !provider_uses_common_config(app_type, provider, snippet) { - return live_settings; - } + strip_common_config_from_live_settings_with_fallback( + app_type, + provider, + live_settings, + snippet, + None, + ) +} +#[expect( + dead_code, + reason = "kept for live-setting fallback stripping when provider snapshots are needed" +)] +pub(super) fn strip_common_config_from_live_settings_or_provider_snapshot( + app_type: &AppType, + provider: &Provider, + live_settings: Value, + snippet: Option<&str>, +) -> Value { + strip_common_config_from_live_settings_with_fallback( + app_type, + provider, + live_settings, + snippet, + Some(&provider.settings_config), + ) +} + +pub(super) fn strip_common_config_snippet_from_live_settings_or_provider_snapshot( + app_type: &AppType, + provider: &Provider, + live_settings: Value, + snippet: Option<&str>, +) -> Value { + strip_common_config_snippet_from_live_settings_with_fallback( + app_type, + provider, + live_settings, + snippet, + Some(&provider.settings_config), + ) +} + +fn strip_common_config_snippet_from_live_settings_with_fallback( + app_type: &AppType, + provider: &Provider, + live_settings: Value, + snippet: Option<&str>, + fallback_settings: Option<&Value>, +) -> Value { let Some(snippet_text) = snippet else { return live_settings; }; + if snippet_text.trim().is_empty() { + return live_settings; + } + match remove_common_config_from_settings(app_type, &live_settings, snippet_text) { Ok(settings) => settings, Err(err) => { @@ -565,11 +615,31 @@ pub(super) fn strip_common_config_from_live_settings( app_type.as_str(), provider.id ); - live_settings + fallback_settings.cloned().unwrap_or(live_settings) } } } +fn strip_common_config_from_live_settings_with_fallback( + app_type: &AppType, + provider: &Provider, + live_settings: Value, + snippet: Option<&str>, + fallback_settings: Option<&Value>, +) -> Value { + if !provider_uses_common_config(app_type, provider, snippet) { + return live_settings; + } + + strip_common_config_snippet_from_live_settings_with_fallback( + app_type, + provider, + live_settings, + snippet, + fallback_settings, + ) +} + pub(super) fn normalize_provider_common_config_for_storage( app_type: &AppType, provider: &mut Provider, diff --git a/src-tauri/src/services/provider/gemini.rs b/src-tauri/src/services/provider/gemini.rs index 2275a6a0..9c519dd8 100644 --- a/src-tauri/src/services/provider/gemini.rs +++ b/src-tauri/src/services/provider/gemini.rs @@ -180,42 +180,68 @@ impl ProviderService { Ok(()) } + #[expect( + dead_code, + reason = "kept for direct Gemini live writes without custom resolution" + )] pub(crate) fn write_gemini_live( provider: &Provider, common_config_snippet: Option<&str>, ) -> Result<(), AppError> { - Self::write_gemini_live_impl(provider, common_config_snippet, false) + Self::write_gemini_live_with_resolution( + provider, + common_config_snippet, + None, + false, + live_merge::ConflictPolicy::Fail.into(), + ) } pub(crate) fn write_gemini_live_force( provider: &Provider, common_config_snippet: Option<&str>, ) -> Result<(), AppError> { - Self::write_gemini_live_impl(provider, common_config_snippet, true) + Self::write_gemini_live_with_resolution( + provider, + common_config_snippet, + None, + true, + live_merge::ConflictPolicy::Fail.into(), + ) } - pub(super) fn write_gemini_live_impl( + pub(super) fn write_gemini_live_with_resolution( provider: &Provider, common_config_snippet: Option<&str>, + previous_common_config_snippet: Option<&str>, force_sync: bool, + resolution: live_merge::ConflictResolution<'_>, ) -> Result<(), AppError> { + let prepared = Self::prepare_gemini_live_write( + provider, + common_config_snippet, + previous_common_config_snippet, + force_sync, + resolution, + )?; + Self::apply_gemini_live_write(&prepared) + } + + pub(super) fn prepare_gemini_live_write( + provider: &Provider, + common_config_snippet: Option<&str>, + previous_common_config_snippet: Option<&str>, + force_sync: bool, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { use crate::gemini_config::{ - get_gemini_settings_path, json_to_env, validate_gemini_settings_strict, - write_gemini_env_atomic, + env_to_json, get_gemini_settings_path, json_to_env, read_gemini_env, + validate_gemini_settings_strict, }; - // 一次性检测认证类型,避免重复检测 let auth_type = Self::detect_gemini_auth_type(provider); - if !force_sync && !crate::sync_policy::should_sync_live(&AppType::Gemini) { - // still update CC-Switch app-level settings, but do not create any ~/.gemini files - match auth_type { - GeminiAuthType::GoogleOfficial => { - Self::ensure_google_oauth_security_flag(provider)? - } - GeminiAuthType::ApiKey => Self::ensure_api_key_security_flag(provider)?, - } - return Ok(()); + return Ok(PreparedLiveWrite::GeminiSecurityFlag { auth_type }); } let content_to_write = Self::build_effective_live_snapshot( @@ -225,81 +251,128 @@ impl ProviderService { common_config_snippet.is_some(), )?; - let mut env_map = json_to_env(&content_to_write)?; + let incoming_env = match auth_type { + GeminiAuthType::GoogleOfficial => std::collections::HashMap::new(), + GeminiAuthType::ApiKey => { + validate_gemini_settings_strict(&content_to_write)?; + json_to_env(&content_to_write)? + } + }; + let mut local_env = { + let local_env = read_gemini_env()?; + let local_settings = env_to_json(&local_env); + let stripped_settings = + common_config::strip_common_config_snippet_from_live_settings_or_provider_snapshot( + &AppType::Gemini, + provider, + local_settings, + previous_common_config_snippet, + ); + json_to_env(&stripped_settings)? + }; + if matches!(auth_type, GeminiAuthType::GoogleOfficial) { + for key in [ + "GEMINI_API_KEY", + "GOOGLE_GEMINI_BASE_URL", + "GEMINI_BASE_URL", + "GEMINI_MODEL", + ] { + local_env.remove(key); + } + } + let env = live_merge::merge_env_live( + &AppType::Gemini, + ".env", + local_env, + &incoming_env, + resolution, + )?; - // 准备要写入 ~/.gemini/settings.json 的配置(缺省时保留现有文件内容) - let settings_path = get_gemini_settings_path(); - let mut config_to_write = if let Some(config_value) = content_to_write.get("config") { - if config_value.is_null() { - None // null → 保留现有文件 - } else if let Some(provider_config) = config_value.as_object() { - if provider_config.is_empty() { - None // 空对象 {} → 保留现有文件 + let mut incoming_config = match content_to_write.get("config") { + Some(Value::Null) | None => json!({}), + Some(config_value) => { + if let Some(provider_config) = config_value.as_object() { + Value::Object(provider_config.clone()) } else { - // 有内容 → 合并到现有 settings.json(保留现有 key,如 mcpServers),供应商优先 - let mut merged = if settings_path.exists() { - read_json_file(&settings_path)? - } else { - json!({}) - }; - - if !merged.is_object() { - merged = json!({}); - } - - let merged_map = merged.as_object_mut().ok_or_else(|| { - AppError::localized( - "gemini.validation.invalid_settings", - "Gemini 现有 settings.json 格式错误: 必须是对象", - "Gemini existing settings.json invalid: must be a JSON object", - ) - })?; - for (key, value) in provider_config { - merged_map.insert(key.clone(), value.clone()); - } - - Some(merged) + return Err(AppError::localized( + "gemini.validation.invalid_config", + "Gemini 配置格式错误: config 必须是对象或 null", + "Gemini config invalid: config must be an object or null", + )); } - } else { - return Err(AppError::localized( - "gemini.validation.invalid_config", - "Gemini 配置格式错误: config 必须是对象或 null", - "Gemini config invalid: config must be an object or null", - )); } + }; + + let config_obj = incoming_config.as_object_mut().ok_or_else(|| { + AppError::localized( + "gemini.validation.invalid_config", + "Gemini 配置格式错误: config 必须是对象或 null", + "Gemini config invalid: config must be an object or null", + ) + })?; + let security = config_obj + .entry("security".to_string()) + .or_insert_with(|| json!({})); + let security_obj = security.as_object_mut().ok_or_else(|| { + AppError::localized( + "gemini.validation.invalid_security", + "Gemini 配置格式错误: security 必须是对象", + "Gemini config invalid: security must be an object", + ) + })?; + let auth = security_obj + .entry("auth".to_string()) + .or_insert_with(|| json!({})); + let auth_obj = auth.as_object_mut().ok_or_else(|| { + AppError::localized( + "gemini.validation.invalid_security_auth", + "Gemini 配置格式错误: security.auth 必须是对象", + "Gemini config invalid: security.auth must be an object", + ) + })?; + auth_obj.insert( + "selectedType".to_string(), + Value::String(Self::gemini_security_selected_type(auth_type).to_string()), + ); + + let settings_path = get_gemini_settings_path(); + let local_config = if settings_path.exists() { + read_json_file(&settings_path)? } else { - None + json!({}) }; + let settings = live_merge::merge_json_live( + &AppType::Gemini, + "settings.json", + local_config, + &incoming_config, + resolution, + )?; - if config_to_write.is_none() { - if settings_path.exists() { - config_to_write = Some(read_json_file(&settings_path)?); - } else { - config_to_write = Some(json!({})); // 新建空配置 - } - } + Ok(PreparedLiveWrite::Gemini { + env, + settings, + auth_type, + }) + } - match auth_type { - GeminiAuthType::GoogleOfficial => { - // Google 官方使用 OAuth,清空 env - env_map.clear(); - write_gemini_env_atomic(&env_map)?; + pub(super) fn apply_gemini_live_write(prepared: &PreparedLiveWrite) -> Result<(), AppError> { + use crate::gemini_config::{get_gemini_settings_path, write_gemini_env_atomic}; + + match prepared { + PreparedLiveWrite::Gemini { + env, + settings, + auth_type, + } => { + write_gemini_env_atomic(env)?; + write_json_file(&get_gemini_settings_path(), settings)?; + Self::ensure_gemini_app_security_flag(*auth_type)?; } - GeminiAuthType::ApiKey => { - // API Key 供应商(所有第三方服务) - // 统一处理:验证配置 + 写入 .env 文件 - validate_gemini_settings_strict(&content_to_write)?; - write_gemini_env_atomic(&env_map)?; + PreparedLiveWrite::GeminiSecurityFlag { auth_type } => { + Self::ensure_gemini_app_security_flag(*auth_type)?; } - } - - if let Some(config_value) = config_to_write { - write_json_file(&settings_path, &config_value)?; - } - - match auth_type { - GeminiAuthType::GoogleOfficial => Self::ensure_google_oauth_security_flag(provider)?, - GeminiAuthType::ApiKey => Self::ensure_api_key_security_flag(provider)?, + _ => {} } Ok(()) diff --git a/src-tauri/src/services/provider/gemini_auth.rs b/src-tauri/src/services/provider/gemini_auth.rs index 12a90b36..77e8c476 100644 --- a/src-tauri/src/services/provider/gemini_auth.rs +++ b/src-tauri/src/services/provider/gemini_auth.rs @@ -54,7 +54,19 @@ impl ProviderService { GeminiAuthType::ApiKey } - /// 确保 Google 官方 Gemini 供应商的安全标志正确设置(OAuth 模式) + pub(super) fn gemini_security_selected_type(auth_type: GeminiAuthType) -> &'static str { + match auth_type { + GeminiAuthType::GoogleOfficial => Self::GOOGLE_OAUTH_SECURITY_SELECTED_TYPE, + GeminiAuthType::ApiKey => Self::API_KEY_SECURITY_SELECTED_TYPE, + } + } + + pub(super) fn ensure_gemini_app_security_flag( + auth_type: GeminiAuthType, + ) -> Result<(), AppError> { + settings::ensure_security_auth_selected_type(Self::gemini_security_selected_type(auth_type)) + } + /// /// Google 官方 Gemini 使用 OAuth 个人认证,不需要 API Key。 /// @@ -89,6 +101,10 @@ impl ProviderService { /// 3. 用户首次使用 Gemini CLI 时,会自动打开浏览器进行 OAuth 登录 /// 4. 登录成功后,凭证保存在 Gemini 的 credential store 中 /// 5. 后续请求自动使用保存的凭证 + #[expect( + dead_code, + reason = "kept for explicit Gemini OAuth security flag updates" + )] pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(), AppError> { // 检测是否为 Google 官方 let auth_type = Self::detect_gemini_auth_type(provider); @@ -127,6 +143,10 @@ impl ProviderService { /// } /// } /// ``` + #[expect( + dead_code, + reason = "kept for explicit Gemini API-key security flag updates" + )] pub(crate) fn ensure_api_key_security_flag(_provider: &Provider) -> Result<(), AppError> { // 写入应用级别的 settings.json (~/.cc-switch/settings.json) settings::ensure_security_auth_selected_type(Self::API_KEY_SECURITY_SELECTED_TYPE)?; diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 6767ee48..eaa7ba1d 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -283,11 +283,9 @@ pub fn sync_openclaw_providers_from_live(state: &AppState) -> Result bool { && provider .meta .as_ref() - .map_or(true, is_default_openclaw_common_config_marker) + .is_none_or(is_default_openclaw_common_config_marker) && provider.icon.is_none() && provider.icon_color.is_none() && !provider.in_failover_queue diff --git a/src-tauri/src/services/provider/live_merge.rs b/src-tauri/src/services/provider/live_merge.rs new file mode 100644 index 00000000..2a276a85 --- /dev/null +++ b/src-tauri/src/services/provider/live_merge.rs @@ -0,0 +1,617 @@ +use std::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use toml_edit::{DocumentMut, Item, TableLike}; + +use crate::app_config::AppType; +use crate::error::AppError; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConfigConflict { + pub app_type: AppType, + pub target: String, + pub path: String, + pub local: String, + pub incoming: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConflictChoice { + KeepLocal, + UseIncoming, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConflictPolicy { + Fail, + PreferLocal, + PreferIncoming, +} + +impl ConflictPolicy { + fn choice_for(self, conflicts: &[ConfigConflict]) -> Result, AppError> { + match self { + ConflictPolicy::Fail => { + if conflicts.is_empty() { + Ok(None) + } else { + Err(conflict_error(conflicts)) + } + } + ConflictPolicy::PreferLocal => Ok(Some(ConflictChoice::KeepLocal)), + ConflictPolicy::PreferIncoming => Ok(Some(ConflictChoice::UseIncoming)), + } + } +} + +pub trait ConflictResolver { + fn resolve_conflict(&mut self, conflict: &ConfigConflict) -> Result; +} + +#[derive(Default)] +pub struct ConflictCollector { + conflicts: Vec, +} + +impl ConflictCollector { + pub fn into_conflicts(self) -> Vec { + self.conflicts + } +} + +impl ConflictResolver for ConflictCollector { + fn resolve_conflict(&mut self, conflict: &ConfigConflict) -> Result { + self.conflicts.push(conflict.clone()); + Ok(ConflictChoice::KeepLocal) + } +} + +impl ConflictResolver for F +where + F: FnMut(&ConfigConflict) -> Result, +{ + fn resolve_conflict(&mut self, conflict: &ConfigConflict) -> Result { + self(conflict) + } +} + +#[derive(Clone, Copy)] +pub enum ConflictResolution<'a> { + Policy(ConflictPolicy), + Resolver(&'a std::cell::RefCell<&'a mut dyn ConflictResolver>), +} + +impl fmt::Debug for ConflictResolution<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Policy(policy) => f.debug_tuple("Policy").field(policy).finish(), + Self::Resolver(_) => f.write_str("Resolver()"), + } + } +} + +impl ConflictResolution<'_> { + fn resolve(self, conflicts: &[ConfigConflict]) -> Result, AppError> { + match self { + Self::Policy(policy) => policy.choice_for(conflicts), + Self::Resolver(resolver) => { + if conflicts.is_empty() { + return Ok(None); + } + + let mut resolver = resolver.borrow_mut(); + let mut choice = None; + for conflict in conflicts { + choice = Some(resolver.resolve_conflict(conflict)?); + } + Ok(choice) + } + } + } + + fn collects_failures(self) -> bool { + matches!(self, Self::Policy(ConflictPolicy::Fail)) + } +} + +impl From for ConflictResolution<'_> { + fn from(policy: ConflictPolicy) -> Self { + Self::Policy(policy) + } +} + +pub fn conflict_error(conflicts: &[ConfigConflict]) -> AppError { + let mut message = String::from("Live configuration has conflicting local changes:"); + for conflict in conflicts { + message.push_str("\n- "); + message.push_str(conflict.app_type.as_str()); + message.push(' '); + message.push_str(&conflict.target); + message.push(' '); + message.push_str(&conflict.path); + message.push_str(" (local: "); + message.push_str(&conflict.local); + message.push_str(", cc-switch: "); + message.push_str(&conflict.incoming); + message.push(')'); + } + AppError::Message(message) +} + +pub fn resolve_conflict_choice( + conflict: ConfigConflict, + resolution: ConflictResolution<'_>, +) -> Result { + Ok(resolution + .resolve(&[conflict])? + .unwrap_or(ConflictChoice::KeepLocal)) +} + +pub fn merge_json_live( + app_type: &AppType, + target: impl Into, + local: Value, + incoming: &Value, + resolution: ConflictResolution<'_>, +) -> Result { + let target = target.into(); + let mut merged = local; + let mut conflicts = Vec::new(); + merge_json_value( + app_type, + &target, + String::new(), + &mut merged, + incoming, + resolution, + &mut conflicts, + )?; + if resolution.collects_failures() && !conflicts.is_empty() { + return Err(conflict_error(&conflicts)); + } + Ok(merged) +} + +fn merge_json_value( + app_type: &AppType, + target: &str, + path: String, + local: &mut Value, + incoming: &Value, + resolution: ConflictResolution<'_>, + conflicts: &mut Vec, +) -> Result<(), AppError> { + match (local, incoming) { + (Value::Object(local_map), Value::Object(incoming_map)) => { + for (key, incoming_value) in incoming_map { + let next_path = json_child_path(&path, key); + match local_map.get_mut(key) { + Some(local_value) => merge_json_value( + app_type, + target, + next_path, + local_value, + incoming_value, + resolution, + conflicts, + )?, + None => { + local_map.insert(key.clone(), incoming_value.clone()); + } + } + } + Ok(()) + } + (local_value, incoming_value) => { + if local_value == incoming_value { + return Ok(()); + } + + let conflict = ConfigConflict { + app_type: app_type.clone(), + target: target.to_string(), + path: display_path(&path), + local: json_display(local_value), + incoming: json_display(incoming_value), + }; + if resolution.collects_failures() { + conflicts.push(conflict); + } else { + match resolution.resolve(&[conflict])? { + Some(ConflictChoice::UseIncoming) => { + *local_value = incoming_value.clone(); + } + Some(ConflictChoice::KeepLocal) | None => {} + } + } + Ok(()) + } + } +} + +pub fn merge_env_live( + app_type: &AppType, + target: impl Into, + mut local: HashMap, + incoming: &HashMap, + resolution: ConflictResolution<'_>, +) -> Result, AppError> { + let target = target.into(); + let mut conflicts = Vec::new(); + for (key, incoming_value) in incoming { + match local.get_mut(key) { + Some(local_value) if local_value != incoming_value => { + let conflict = ConfigConflict { + app_type: app_type.clone(), + target: target.clone(), + path: key.clone(), + local: local_value.clone(), + incoming: incoming_value.clone(), + }; + if resolution.collects_failures() { + conflicts.push(conflict); + } else if matches!( + resolution.resolve(&[conflict])?, + Some(ConflictChoice::UseIncoming) + ) { + *local_value = incoming_value.clone(); + } + } + Some(_) => {} + None => { + local.insert(key.clone(), incoming_value.clone()); + } + } + } + if resolution.collects_failures() && !conflicts.is_empty() { + return Err(conflict_error(&conflicts)); + } + Ok(local) +} + +pub fn merge_toml_live( + app_type: &AppType, + target: impl Into, + local_text: &str, + incoming_text: &str, + resolution: ConflictResolution<'_>, +) -> Result { + let target = target.into(); + let mut local_doc = parse_toml_live(local_text, &target)?; + let incoming_doc = parse_toml_live(incoming_text, &target)?; + let mut conflicts = Vec::new(); + merge_toml_table_like( + app_type, + &target, + String::new(), + local_doc.as_table_mut(), + incoming_doc.as_table(), + resolution, + &mut conflicts, + )?; + if resolution.collects_failures() && !conflicts.is_empty() { + return Err(conflict_error(&conflicts)); + } + Ok(local_doc.to_string()) +} + +fn parse_toml_live(text: &str, target: &str) -> Result { + text.trim() + .parse::() + .map_err(|e| AppError::Config(format!("TOML parse error in {target}: {e}"))) +} + +fn merge_toml_item( + app_type: &AppType, + target: &str, + path: String, + local: &mut Item, + incoming: &Item, + resolution: ConflictResolution<'_>, + conflicts: &mut Vec, +) -> Result<(), AppError> { + if let Some(incoming_table) = incoming.as_table_like() { + if let Some(local_table) = local.as_table_like_mut() { + return merge_toml_table_like( + app_type, + target, + path, + local_table, + incoming_table, + resolution, + conflicts, + ); + } + } + + if toml_items_equal(local, incoming) { + return Ok(()); + } + + let conflict = ConfigConflict { + app_type: app_type.clone(), + target: target.to_string(), + path: display_path(&path), + local: toml_display(local), + incoming: toml_display(incoming), + }; + if resolution.collects_failures() { + conflicts.push(conflict); + } else if matches!( + resolution.resolve(&[conflict])?, + Some(ConflictChoice::UseIncoming) + ) { + *local = incoming.clone(); + } + Ok(()) +} + +fn merge_toml_table_like( + app_type: &AppType, + target: &str, + path: String, + local: &mut dyn TableLike, + incoming: &dyn TableLike, + resolution: ConflictResolution<'_>, + conflicts: &mut Vec, +) -> Result<(), AppError> { + for (key, incoming_item) in incoming.iter() { + let next_path = toml_child_path(&path, key); + match local.get_mut(key) { + Some(local_item) => merge_toml_item( + app_type, + target, + next_path, + local_item, + incoming_item, + resolution, + conflicts, + )?, + None => { + local.insert(key, incoming_item.clone()); + } + } + } + Ok(()) +} + +fn toml_items_equal(left: &Item, right: &Item) -> bool { + match (left.as_value(), right.as_value()) { + (Some(left_value), Some(right_value)) => { + left_value.to_string().trim() == right_value.to_string().trim() + } + _ => left.to_string().trim() == right.to_string().trim(), + } +} + +fn json_child_path(parent: &str, key: &str) -> String { + if parent.is_empty() { + key.to_string() + } else { + format!("{parent}.{key}") + } +} + +fn toml_child_path(parent: &str, key: &str) -> String { + if parent.is_empty() { + key.to_string() + } else { + format!("{parent}.{key}") + } +} + +fn display_path(path: &str) -> String { + if path.is_empty() { + "".to_string() + } else { + path.to_string() + } +} + +fn json_display(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + } +} + +fn toml_display(item: &Item) -> String { + item.to_string().trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn json_merge_preserves_local_and_adds_incoming_nested_keys() { + let local = json!({ + "env": { + "LOCAL": "keep", + "SAME": "value" + } + }); + let incoming = json!({ + "env": { + "REMOTE": "add", + "SAME": "value" + } + }); + + let merged = merge_json_live( + &AppType::Claude, + "settings.json", + local, + &incoming, + ConflictPolicy::Fail.into(), + ) + .unwrap(); + + assert_eq!( + merged, + json!({ + "env": { + "LOCAL": "keep", + "REMOTE": "add", + "SAME": "value" + } + }) + ); + } + + #[test] + fn json_merge_conflicts_on_scalar_difference() { + let local = json!({ "env": { "MODEL": "local" } }); + let incoming = json!({ "env": { "MODEL": "incoming" } }); + + let err = merge_json_live( + &AppType::Claude, + "settings.json", + local, + &incoming, + ConflictPolicy::Fail.into(), + ) + .unwrap_err(); + + assert!(err.to_string().contains("env.MODEL")); + } + + #[test] + fn json_merge_collects_multiple_fail_policy_conflicts() { + let local = json!({ + "env": { + "MODEL": "local", + "TOKEN": "local-token" + } + }); + let incoming = json!({ + "env": { + "MODEL": "incoming", + "TOKEN": "incoming-token" + } + }); + + let err = merge_json_live( + &AppType::Claude, + "settings.json", + local, + &incoming, + ConflictPolicy::Fail.into(), + ) + .unwrap_err(); + let message = err.to_string(); + + assert!(message.contains("env.MODEL")); + assert!(message.contains("env.TOKEN")); + } + + #[test] + fn json_merge_can_prefer_incoming_conflict() { + let local = json!({ "array": ["local"] }); + let incoming = json!({ "array": ["incoming"] }); + + let merged = merge_json_live( + &AppType::Claude, + "settings.json", + local, + &incoming, + ConflictPolicy::PreferIncoming.into(), + ) + .unwrap(); + + assert_eq!(merged, json!({ "array": ["incoming"] })); + } + + #[test] + fn toml_merge_preserves_local_and_adds_nested_incoming_keys() { + let local = r#" +model = "sonnet" +[model_providers.local] +base_url = "https://local.example" +"#; + let incoming = r#" +model = "sonnet" +[model_providers.local] +api_key_env_var = "KEY" +"#; + + let merged = merge_toml_live( + &AppType::Codex, + "config.toml", + local, + incoming, + ConflictPolicy::Fail.into(), + ) + .unwrap(); + + assert!(merged.contains("base_url = \"https://local.example\"")); + assert!(merged.contains("api_key_env_var = \"KEY\"")); + } + + #[test] + fn toml_merge_conflicts_on_scalar_difference() { + let err = merge_toml_live( + &AppType::Codex, + "config.toml", + "model = \"local\"", + "model = \"incoming\"", + ConflictPolicy::Fail.into(), + ) + .unwrap_err(); + + assert!(err.to_string().contains("model")); + } + + #[test] + fn toml_merge_collects_multiple_fail_policy_conflicts() { + let err = merge_toml_live( + &AppType::Codex, + "config.toml", + r#" +model = "local" +model_provider = "local-provider" +"#, + r#" +model = "incoming" +model_provider = "incoming-provider" +"#, + ConflictPolicy::Fail.into(), + ) + .unwrap_err(); + let message = err.to_string(); + + assert!(message.contains("model")); + assert!(message.contains("model_provider")); + } + + #[test] + fn env_merge_collects_multiple_fail_policy_conflicts() { + let local = HashMap::from([ + ("API_KEY".to_string(), "local".to_string()), + ("BASE_URL".to_string(), "https://local.example".to_string()), + ]); + let incoming = HashMap::from([ + ("API_KEY".to_string(), "incoming".to_string()), + ( + "BASE_URL".to_string(), + "https://incoming.example".to_string(), + ), + ]); + + let err = merge_env_live( + &AppType::Gemini, + ".env", + local, + &incoming, + ConflictPolicy::Fail.into(), + ) + .unwrap_err(); + let message = err.to_string(); + + assert!(message.contains("API_KEY")); + assert!(message.contains("BASE_URL")); + } +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index c7248582..eaab12a4 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -10,6 +10,7 @@ mod endpoints; mod gemini; mod gemini_auth; mod live; +pub(crate) mod live_merge; mod models; #[cfg(test)] mod tests; @@ -101,10 +102,59 @@ struct PostCommitAction { sync_mcp: bool, refresh_snapshot: bool, common_config_snippet: Option, + previous_common_config_snippet: Option, takeover_active: bool, activate_provider: bool, } +#[derive(Clone)] +struct PreparedPostCommitAction { + action: PostCommitAction, + effect: PreparedPostCommitEffect, +} + +#[derive(Clone)] +enum PreparedPostCommitEffect { + Live(PreparedLiveWrite), + ProxyLiveBackup(Value), +} + +#[derive(Clone)] +enum PreparedLiveWrite { + Noop, + Claude { + settings: Value, + }, + Codex { + auth: PreparedCodexAuthWrite, + config: crate::codex_config::PreparedCodexConfigText, + }, + Gemini { + env: std::collections::HashMap, + settings: Value, + auth_type: GeminiAuthType, + }, + GeminiSecurityFlag { + auth_type: GeminiAuthType, + }, + OpenCode { + config: Value, + }, + Hermes { + providers: serde_yaml::Value, + }, + OpenClaw { + models: Value, + }, +} + +#[derive(Clone)] +enum PreparedCodexAuthWrite { + Preserve, + Write(Value), + Delete, +} + impl ProviderService { pub fn is_provider_key_app(app_type: &AppType) -> bool { matches!(app_type, AppType::OpenClaw | AppType::Hermes) @@ -541,19 +591,57 @@ impl ProviderService { where F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option), AppError>, { - let mut guard = state.config.write().map_err(AppError::from)?; - let original = guard.clone(); - let (result, action) = match f(&mut guard) { - Ok(value) => value, - Err(err) => { - *guard = original; - return Err(err); - } + Self::run_transaction_with_resolution(state, live_merge::ConflictPolicy::Fail.into(), f) + } + + fn run_transaction_with_resolution( + state: &AppState, + resolution: live_merge::ConflictResolution<'_>, + f: F, + ) -> Result + where + F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option), AppError>, + { + Self::run_staged_transaction(state, None, resolution, f) + } + + fn run_staged_transaction( + state: &AppState, + preserved_current_apps: Option<&[AppType]>, + resolution: live_merge::ConflictResolution<'_>, + f: F, + ) -> Result + where + F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option), AppError>, + { + let original = { + let guard = state.config.read().map_err(AppError::from)?; + guard.clone() }; - drop(guard); + let mut candidate = original.clone(); + let (result, action) = f(&mut candidate)?; + let prepared = action + .map(|action| Self::prepare_post_commit_action(state, action, resolution)) + .transpose()?; - if let Err(save_err) = state.save() { - if let Err(rollback_err) = Self::restore_config_only(state, original.clone()) { + { + let mut guard = state.config.write().map_err(AppError::from)?; + *guard = candidate.clone(); + } + + let save_result = match preserved_current_apps { + Some(apps) => state.save_config_snapshot_preserving_current_providers(&candidate, apps), + None => state.save_config_snapshot(&candidate), + }; + + if let Err(save_err) = save_result { + let rollback_result = match preserved_current_apps { + Some(apps) => { + Self::restore_config_only_preserving_current_providers(state, original, apps) + } + None => Self::restore_config_only(state, original), + }; + if let Err(rollback_err) = rollback_result { return Err(AppError::localized( "config.save.rollback_failed", format!("保存配置失败: {save_err};回滚失败: {rollback_err}"), @@ -563,11 +651,22 @@ impl ProviderService { return Err(save_err); } - if let Some(action) = action { - if let Err(err) = Self::apply_post_commit(state, &action) { - if let Err(rollback_err) = - Self::rollback_after_failure(state, original.clone(), action.backup.clone()) - { + if let Some(prepared) = prepared { + if let Err(err) = Self::apply_prepared_post_commit_action(state, &prepared) { + let rollback_result = match preserved_current_apps { + Some(apps) => Self::rollback_after_failure_preserving_current_providers( + state, + original, + apps, + prepared.action.backup.clone(), + ), + None => Self::rollback_after_failure( + state, + original, + prepared.action.backup.clone(), + ), + }; + if let Err(rollback_err) = rollback_result { return Err(AppError::localized( "post_commit.rollback_failed", format!("后置操作失败: {err};回滚失败: {rollback_err}"), @@ -581,6 +680,10 @@ impl ProviderService { Ok(result) } + #[expect( + dead_code, + reason = "kept for callers that preserve current providers without custom resolution" + )] fn run_transaction_preserving_current_providers( state: &AppState, preserved_current_apps: &[AppType], @@ -589,51 +692,24 @@ impl ProviderService { where F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option), AppError>, { - let mut guard = state.config.write().map_err(AppError::from)?; - let original = guard.clone(); - let (result, action) = match f(&mut guard) { - Ok(value) => value, - Err(err) => { - *guard = original; - return Err(err); - } - }; - drop(guard); - - if let Err(save_err) = state.save_preserving_current_providers(preserved_current_apps) { - if let Err(rollback_err) = Self::restore_config_only_preserving_current_providers( - state, - original.clone(), - preserved_current_apps, - ) { - return Err(AppError::localized( - "config.save.rollback_failed", - format!("保存配置失败: {save_err};回滚失败: {rollback_err}"), - format!("Failed to save config: {save_err}; rollback failed: {rollback_err}"), - )); - } - return Err(save_err); - } - - if let Some(action) = action { - if let Err(err) = Self::apply_post_commit(state, &action) { - if let Err(rollback_err) = Self::rollback_after_failure_preserving_current_providers( - state, - original.clone(), - preserved_current_apps, - action.backup.clone(), - ) { - return Err(AppError::localized( - "post_commit.rollback_failed", - format!("后置操作失败: {err};回滚失败: {rollback_err}"), - format!("Post-commit step failed: {err}; rollback failed: {rollback_err}"), - )); - } - return Err(err); - } - } + Self::run_transaction_preserving_current_providers_with_resolution( + state, + preserved_current_apps, + live_merge::ConflictPolicy::Fail.into(), + f, + ) + } - Ok(result) + fn run_transaction_preserving_current_providers_with_resolution( + state: &AppState, + preserved_current_apps: &[AppType], + resolution: live_merge::ConflictResolution<'_>, + f: F, + ) -> Result + where + F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option), AppError>, + { + Self::run_staged_transaction(state, Some(preserved_current_apps), resolution, f) } fn restore_config_only(state: &AppState, snapshot: MultiAppConfig) -> Result<(), AppError> { @@ -679,14 +755,23 @@ impl ProviderService { backup.restore() } - fn apply_post_commit(state: &AppState, action: &PostCommitAction) -> Result<(), AppError> { - if action.takeover_active { - futures::executor::block_on( + fn prepare_post_commit_action( + state: &AppState, + action: PostCommitAction, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { + let effect = if action.takeover_active { + let backup_snapshot = futures::executor::block_on( state .proxy_service - .update_live_backup_from_provider(action.app_type.as_str(), &action.provider), + .prepare_live_backup_from_provider_with_resolution( + action.app_type.as_str(), + &action.provider, + resolution, + ), ) .map_err(AppError::Message)?; + PreparedPostCommitEffect::ProxyLiveBackup(backup_snapshot) } else { let apply_common_config = action .provider @@ -694,32 +779,60 @@ impl ProviderService { .as_ref() .and_then(|meta| meta.apply_common_config) .unwrap_or(false); - Self::write_live_snapshot( + PreparedPostCommitEffect::Live(Self::prepare_live_snapshot_with_resolution( &action.app_type, &action.provider, action.common_config_snippet.as_deref(), + action.previous_common_config_snippet.as_deref(), apply_common_config, - )?; - if action.activate_provider && matches!(action.app_type, AppType::Hermes) { - crate::hermes_config::set_current_provider( - &action.provider.id, - &action.provider.settings_config, - )?; + resolution, + )?) + }; + + Ok(PreparedPostCommitAction { action, effect }) + } + + fn apply_prepared_post_commit_action( + state: &AppState, + prepared: &PreparedPostCommitAction, + ) -> Result<(), AppError> { + match &prepared.effect { + PreparedPostCommitEffect::Live(live) => { + Self::apply_prepared_live_snapshot(live)?; + if prepared.action.activate_provider + && matches!(prepared.action.app_type, AppType::Hermes) + { + crate::hermes_config::set_current_provider( + &prepared.action.provider.id, + &prepared.action.provider.settings_config, + )?; + } + } + PreparedPostCommitEffect::ProxyLiveBackup(snapshot) => { + futures::executor::block_on( + state + .proxy_service + .save_live_backup_snapshot(prepared.action.app_type.as_str(), snapshot), + ) + .map_err(AppError::Message)?; } } - if action.sync_mcp { - // 使用 v3.7.0 统一的 MCP 同步机制,支持所有应用 + + if prepared.action.sync_mcp { use crate::services::mcp::McpService; McpService::sync_all_enabled(state)?; } - if !action.takeover_active - && action.refresh_snapshot - && crate::sync_policy::should_sync_live(&action.app_type) + if !prepared.action.takeover_active + && prepared.action.refresh_snapshot + && crate::sync_policy::should_sync_live(&prepared.action.app_type) { - Self::refresh_provider_snapshot(state, &action.app_type, &action.provider.id)?; + Self::refresh_provider_snapshot( + state, + &prepared.action.app_type, + &prepared.action.provider.id, + )?; } - // D6: Align upstream live flows - also sync skills (best effort, should not block provider ops). if let Err(e) = crate::services::skill::SkillService::sync_all_enabled_best_effort() { log::warn!("同步 Skills 失败: {e}"); } @@ -1091,6 +1204,7 @@ impl ProviderService { app_type: &AppType, current_provider_id: Option<&str>, takeover_active: bool, + previous_common_config_snippet: Option, ) -> Result, AppError> { if app_type.is_additive_mode() { return Ok(None); @@ -1103,8 +1217,9 @@ impl ProviderService { Self::build_post_commit_action_for_current_provider( config, app_type, - ¤t_provider_id, + current_provider_id, takeover_active, + previous_common_config_snippet, ) } @@ -1113,6 +1228,7 @@ impl ProviderService { app_type: &AppType, current_provider_id: &str, takeover_active: bool, + previous_common_config_snippet: Option, ) -> Result, AppError> { let provider = config .get_manager(app_type) @@ -1129,6 +1245,7 @@ impl ProviderService { sync_mcp: matches!(app_type, AppType::Codex) && !takeover_active, refresh_snapshot: false, common_config_snippet: config.common_config_snippets.get(app_type).cloned(), + previous_common_config_snippet, takeover_active, activate_provider: false, })) @@ -1521,6 +1638,20 @@ impl ProviderService { state: &AppState, app_type: AppType, snippet: Option, + ) -> Result<(), AppError> { + Self::set_common_config_snippet_with_resolution( + state, + app_type, + snippet, + live_merge::ConflictPolicy::Fail.into(), + ) + } + + pub(crate) fn set_common_config_snippet_with_resolution( + state: &AppState, + app_type: AppType, + snippet: Option, + resolution: live_merge::ConflictResolution<'_>, ) -> Result<(), AppError> { let normalized_snippet = snippet.and_then(|value| { let trimmed = value.trim(); @@ -1590,9 +1721,10 @@ impl ProviderService { } }; - Self::run_transaction_preserving_current_providers( + Self::run_transaction_preserving_current_providers_with_resolution( state, std::slice::from_ref(&app_type), + resolution, move |config| { config.ensure_app(&app_type_clone); @@ -1645,6 +1777,7 @@ impl ProviderService { &app_type_clone, effective_current_provider.as_deref(), takeover_active, + old_snippet, )?; Ok(((), action)) }, @@ -1690,6 +1823,20 @@ impl ProviderService { /// 新增供应商 pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result { + Self::add_with_resolution( + state, + app_type, + provider, + live_merge::ConflictPolicy::Fail.into(), + ) + } + + pub(crate) fn add_with_resolution( + state: &AppState, + app_type: AppType, + provider: Provider, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { let mut provider = provider; // 归一化 Claude 模型键 Self::normalize_provider_if_claude(&app_type, &mut provider); @@ -1704,7 +1851,7 @@ impl ProviderService { state.db.get_current_provider(app_type.as_str())? }; - Self::run_transaction(state, move |config| { + Self::run_transaction_with_resolution(state, resolution, move |config| { let common_config_snippet = config.common_config_snippets.get(&app_type_clone).cloned(); let mut provider_to_store = provider_clone.clone(); Self::normalize_provider_for_storage( @@ -1765,6 +1912,7 @@ impl ProviderService { sync_mcp: matches!(&app_type_clone, AppType::Codex), refresh_snapshot: false, common_config_snippet, + previous_common_config_snippet: None, takeover_active: false, activate_provider: false, }) @@ -1781,6 +1929,20 @@ impl ProviderService { state: &AppState, app_type: AppType, provider: Provider, + ) -> Result { + Self::update_with_resolution( + state, + app_type, + provider, + live_merge::ConflictPolicy::Fail.into(), + ) + } + + pub(crate) fn update_with_resolution( + state: &AppState, + app_type: AppType, + provider: Provider, + resolution: live_merge::ConflictResolution<'_>, ) -> Result { let mut provider = provider; // 归一化 Claude 模型键 @@ -1798,7 +1960,7 @@ impl ProviderService { ) }; - Self::run_transaction(state, move |config| { + Self::run_transaction_with_resolution(state, resolution, move |config| { let common_config_snippet = config.common_config_snippets.get(&app_type_clone).cloned(); let manager = config .get_manager_mut(&app_type_clone) @@ -1910,6 +2072,7 @@ impl ProviderService { sync_mcp: matches!(&app_type_clone, AppType::Codex), refresh_snapshot: false, common_config_snippet, + previous_common_config_snippet: None, takeover_active: false, activate_provider: false, }) @@ -2239,7 +2402,7 @@ impl ProviderService { AppType::OpenCode => Self::import_opencode_providers_from_live(state), AppType::OpenClaw => Self::import_openclaw_providers_from_live(state), AppType::Hermes => Self::import_hermes_providers_from_live(state), - _ => Self::import_default_config(state, app_type).map(|imported| usize::from(imported)), + _ => Self::import_default_config(state, app_type).map(usize::from), } } @@ -2371,6 +2534,13 @@ impl ProviderService { /// (`~/.codex/config.toml`、Claude `settings.json` 等)尚未同步。 /// 对齐上游 `sync_current_to_live` 行为。 pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { + Self::sync_current_to_live_with_resolution(state, live_merge::ConflictPolicy::Fail.into()) + } + + pub(crate) fn sync_current_to_live_with_resolution( + state: &AppState, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result<(), AppError> { use crate::services::mcp::McpService; // 在读锁下收集所有需要的数据,避免持锁写文件 @@ -2432,10 +2602,14 @@ impl ProviderService { continue; } - if let Err(e) = Self::write_live_snapshot(app_type, provider, snippet.as_deref(), true) - { - log::warn!("sync_current_to_live: 写入 {app_type} live 配置失败: {e}"); - } + Self::write_live_snapshot_with_resolution( + app_type, + provider, + snippet.as_deref(), + None, + true, + resolution, + )?; } if let Err(e) = @@ -2455,8 +2629,157 @@ impl ProviderService { Ok(()) } + pub(crate) fn preview_switch_live_conflicts( + state: &AppState, + app_type: AppType, + provider_id: &str, + ) -> Result, AppError> { + if !app_type.is_additive_mode() { + let providers = state.db.get_all_providers(app_type.as_str())?; + providers.get(provider_id).ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), + ) + })?; + + let is_app_taken_over = + futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) + .ok() + .flatten() + .is_some(); + let running_takeover_active = state + .proxy_service + .is_app_takeover_active_blocking(&app_type) + .map_err(AppError::Message)?; + let live_taken_over = state + .proxy_service + .detect_takeover_in_live_config_for_app(&app_type); + if is_app_taken_over || live_taken_over || running_takeover_active { + return Ok(Vec::new()); + } + } + + let effective_current_provider = if app_type.is_additive_mode() { + None + } else { + crate::settings::get_effective_current_provider(&state.db, &app_type)? + }; + let previous_common_config_snippet = if app_type.is_additive_mode() { + None + } else { + state.db.get_config_snippet(app_type.as_str())? + }; + let mut candidate = { + let guard = state.config.read().map_err(AppError::from)?; + guard.clone() + }; + let action = Self::prepare_switch_post_commit_action( + &mut candidate, + &app_type, + provider_id, + effective_current_provider.as_deref(), + previous_common_config_snippet, + )?; + + let mut collector = live_merge::ConflictCollector::default(); + { + let resolver = &mut collector as &mut dyn live_merge::ConflictResolver; + let resolver = std::cell::RefCell::new(resolver); + Self::prepare_post_commit_action( + state, + action, + live_merge::ConflictResolution::Resolver(&resolver), + )?; + } + + Ok(collector.into_conflicts()) + } + + fn prepare_switch_post_commit_action( + config: &mut MultiAppConfig, + app_type: &AppType, + provider_id: &str, + effective_current_provider: Option<&str>, + previous_common_config_snippet: Option, + ) -> Result { + if app_type.is_additive_mode() { + let provider = { + let provider = config + .get_manager_mut(app_type) + .ok_or_else(|| Self::app_not_found(app_type))? + .providers + .get_mut(provider_id) + .ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), + ) + })?; + Self::set_provider_live_config_managed(provider, true); + provider.clone() + }; + + return Ok(PostCommitAction { + app_type: app_type.clone(), + provider, + backup: Self::capture_live_snapshot(app_type)?, + sync_mcp: matches!(app_type, AppType::OpenCode), + refresh_snapshot: false, + common_config_snippet: config.common_config_snippets.get(app_type).cloned(), + previous_common_config_snippet: None, + takeover_active: false, + activate_provider: matches!(app_type, AppType::Hermes), + }); + } + + let backup = Self::capture_live_snapshot(app_type)?; + let provider = match app_type { + AppType::Codex => { + Self::prepare_switch_codex(config, provider_id, effective_current_provider)? + } + AppType::Claude => { + Self::prepare_switch_claude(config, provider_id, effective_current_provider)? + } + AppType::Gemini => { + Self::prepare_switch_gemini(config, provider_id, effective_current_provider)? + } + AppType::OpenCode => unreachable!("additive mode handled above"), + AppType::Hermes => unreachable!("additive mode handled above"), + AppType::OpenClaw => unreachable!("additive mode handled above"), + }; + + Ok(PostCommitAction { + app_type: app_type.clone(), + provider, + backup, + sync_mcp: true, + refresh_snapshot: true, + common_config_snippet: config.common_config_snippets.get(app_type).cloned(), + previous_common_config_snippet, + takeover_active: false, + activate_provider: false, + }) + } + /// 切换指定应用的供应商 pub fn switch(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> { + Self::switch_with_resolution( + state, + app_type, + provider_id, + live_merge::ConflictPolicy::Fail.into(), + ) + } + + pub(crate) fn switch_with_resolution( + state: &AppState, + app_type: AppType, + provider_id: &str, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result<(), AppError> { if !app_type.is_additive_mode() { let providers = state.db.get_all_providers(app_type.as_str())?; providers.get(provider_id).ok_or_else(|| { @@ -2472,14 +2795,14 @@ impl ProviderService { .ok() .flatten() .is_some(); - let is_proxy_running = state + let running_takeover_active = state .proxy_service - .is_running_blocking() + .is_app_takeover_active_blocking(&app_type) .map_err(AppError::Message)?; let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); - let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running; + let should_hot_switch = is_app_taken_over || live_taken_over || running_takeover_active; if should_hot_switch { futures::executor::block_on( @@ -2504,76 +2827,20 @@ impl ProviderService { } else { crate::settings::get_effective_current_provider(&state.db, &app_type)? }; + let previous_common_config_snippet = if app_type.is_additive_mode() { + None + } else { + state.db.get_config_snippet(app_type.as_str())? + }; - Self::run_transaction(state, move |config| { - if app_type_clone.is_additive_mode() { - let provider = { - let provider = config - .get_manager_mut(&app_type_clone) - .ok_or_else(|| Self::app_not_found(&app_type_clone))? - .providers - .get_mut(&provider_id_owned) - .ok_or_else(|| { - AppError::localized( - "provider.not_found", - format!("供应商不存在: {provider_id_owned}"), - format!("Provider not found: {provider_id_owned}"), - ) - })?; - Self::set_provider_live_config_managed(provider, true); - provider.clone() - }; - - let action = PostCommitAction { - app_type: app_type_clone.clone(), - provider, - backup: Self::capture_live_snapshot(&app_type_clone)?, - sync_mcp: matches!(app_type_clone, AppType::OpenCode), - refresh_snapshot: false, - common_config_snippet: config - .common_config_snippets - .get(&app_type_clone) - .cloned(), - takeover_active: false, - activate_provider: matches!(&app_type_clone, AppType::Hermes), - }; - - return Ok(((), Some(action))); - } - - let backup = Self::capture_live_snapshot(&app_type_clone)?; - let provider = match app_type_clone { - AppType::Codex => Self::prepare_switch_codex( - config, - &provider_id_owned, - effective_current_provider.as_deref(), - )?, - AppType::Claude => Self::prepare_switch_claude( - config, - &provider_id_owned, - effective_current_provider.as_deref(), - )?, - AppType::Gemini => Self::prepare_switch_gemini( - config, - &provider_id_owned, - effective_current_provider.as_deref(), - )?, - AppType::OpenCode => unreachable!("additive mode handled above"), - AppType::Hermes => unreachable!("additive mode handled above"), - AppType::OpenClaw => unreachable!("additive mode handled above"), - }; - - let action = PostCommitAction { - app_type: app_type_clone.clone(), - provider, - backup, - sync_mcp: true, // v3.7.0: 所有应用切换时都同步 MCP,防止配置丢失 - refresh_snapshot: true, - common_config_snippet: config.common_config_snippets.get(&app_type_clone).cloned(), - takeover_active: false, - activate_provider: false, - }; - + Self::run_transaction_with_resolution(state, resolution, move |config| { + let action = Self::prepare_switch_post_commit_action( + config, + &app_type_clone, + &provider_id_owned, + effective_current_provider.as_deref(), + previous_common_config_snippet, + )?; Ok(((), Some(action))) })?; @@ -2590,6 +2857,43 @@ impl ProviderService { common_config_snippet: Option<&str>, apply_common_config: bool, ) -> Result<(), AppError> { + Self::write_live_snapshot_with_resolution( + app_type, + provider, + common_config_snippet, + None, + apply_common_config, + live_merge::ConflictPolicy::Fail.into(), + ) + } + + fn write_live_snapshot_with_resolution( + app_type: &AppType, + provider: &Provider, + common_config_snippet: Option<&str>, + previous_common_config_snippet: Option<&str>, + apply_common_config: bool, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result<(), AppError> { + let prepared = Self::prepare_live_snapshot_with_resolution( + app_type, + provider, + common_config_snippet, + previous_common_config_snippet, + apply_common_config, + resolution, + )?; + Self::apply_prepared_live_snapshot(&prepared) + } + + fn prepare_live_snapshot_with_resolution( + app_type: &AppType, + provider: &Provider, + common_config_snippet: Option<&str>, + previous_common_config_snippet: Option<&str>, + apply_common_config: bool, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { let apply_common_config = Self::resolve_live_apply_common_config( app_type, provider, @@ -2598,19 +2902,32 @@ impl ProviderService { ); match app_type { - AppType::Codex => { - Self::write_codex_live(provider, common_config_snippet, apply_common_config) - } - AppType::Claude => { - Self::write_claude_live(provider, common_config_snippet, apply_common_config) - } - AppType::Gemini => Self::write_gemini_live( + AppType::Codex => Self::prepare_codex_live_write( + provider, + common_config_snippet, + previous_common_config_snippet, + apply_common_config, + false, + resolution, + ), + AppType::Claude => Self::prepare_claude_live_write( + provider, + common_config_snippet, + previous_common_config_snippet, + apply_common_config, + false, + resolution, + ), + AppType::Gemini => Self::prepare_gemini_live_write( provider, if apply_common_config { common_config_snippet } else { None }, + previous_common_config_snippet, + false, + resolution, ), AppType::OpenCode => { let config_to_write = if let Some(obj) = provider.settings_config.as_object() { @@ -2626,12 +2943,21 @@ impl ProviderService { provider.settings_config.clone() }; - match serde_json::from_value::( + let config = match serde_json::from_value::( config_to_write.clone(), ) { - Ok(config) => crate::opencode_config::set_typed_provider(&provider.id, &config), - Err(_) => crate::opencode_config::set_provider(&provider.id, config_to_write), - } + Ok(config) => crate::opencode_config::prepare_typed_provider_with_resolution( + &provider.id, + &config, + resolution, + )?, + Err(_) => crate::opencode_config::prepare_provider_with_resolution( + &provider.id, + config_to_write, + resolution, + )?, + }; + Ok(PreparedLiveWrite::OpenCode { config }) } AppType::Hermes => { if !provider.settings_config.is_object() { @@ -2641,8 +2967,12 @@ impl ProviderService { "Hermes configuration must be a JSON object", )); } - crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone()) - .map(|_| ()) + let providers = crate::hermes_config::prepare_provider_with_resolution( + &provider.id, + provider.settings_config.clone(), + resolution, + )?; + Ok(PreparedLiveWrite::Hermes { providers }) } AppType::OpenClaw => { let settings_config = provider.settings_config.clone(); @@ -2650,15 +2980,40 @@ impl ProviderService { || settings_config.get("api").is_some() || settings_config.get("models").is_some(); if !looks_like_provider { - return Ok(()); + return Ok(PreparedLiveWrite::Noop); } let config = Self::parse_openclaw_provider_settings(&settings_config)?; Self::validate_openclaw_provider_models(&provider.id, &config)?; - let write_result = - crate::openclaw_config::set_typed_provider(&provider.id, &config).map(|_| ()); + let models = crate::openclaw_config::prepare_typed_provider_with_resolution( + &provider.id, + &config, + resolution, + ) + .map_err(Self::normalize_openclaw_live_write_error)?; + Ok(PreparedLiveWrite::OpenClaw { models }) + } + } + } - write_result.map_err(Self::normalize_openclaw_live_write_error) + fn apply_prepared_live_snapshot(prepared: &PreparedLiveWrite) -> Result<(), AppError> { + match prepared { + PreparedLiveWrite::Noop => Ok(()), + PreparedLiveWrite::Claude { .. } => Self::apply_claude_live_write(prepared), + PreparedLiveWrite::Codex { .. } => Self::apply_codex_live_write(prepared), + PreparedLiveWrite::Gemini { .. } | PreparedLiveWrite::GeminiSecurityFlag { .. } => { + Self::apply_gemini_live_write(prepared) + } + PreparedLiveWrite::OpenCode { config } => { + crate::opencode_config::write_prepared_config(config) + } + PreparedLiveWrite::Hermes { providers } => { + crate::hermes_config::write_prepared_providers(providers).map(|_| ()) + } + PreparedLiveWrite::OpenClaw { models } => { + crate::openclaw_config::write_prepared_models(models) + .map(|_| ()) + .map_err(Self::normalize_openclaw_live_write_error) } } } diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index e59a8c1c..130c01a3 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -21,6 +21,10 @@ fn with_common_enabled(mut provider: Provider) -> Provider { provider } +fn prefer_incoming_conflicts() -> live_merge::ConflictResolution<'static> { + live_merge::ConflictPolicy::PreferIncoming.into() +} + #[test] fn extract_codex_common_config_excludes_profile_model_selection() { let extracted = ProviderService::extract_codex_common_config_from_config_toml( @@ -203,14 +207,12 @@ fn setup_switched_codex_state_with_managed_mcp() -> (TempDir, EnvGuard, AppState std::fs::write( get_codex_config_path(), - r#"model_provider = "azure" + r#"model_provider = "second" model = "gpt-4" disable_response_storage = true -[model_providers.azure] -name = "Azure OpenAI" -base_url = "https://azure.example/v1" -wire_api = "responses" +[model_providers.second] +base_url = "https://api.two.example/v1" [mcp_servers.my_server] command = "npx" @@ -219,7 +221,13 @@ command = "npx" .expect("seed live config.toml"); let state = state_from_config(config); - ProviderService::switch(&state, AppType::Codex, "p2").expect("switch should succeed"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch should succeed"); (temp_home, env, state) } @@ -558,8 +566,13 @@ fn codex_switch_overwrites_existing_auth_json_for_openai_official_provider() { let state = state_from_config(config); - ProviderService::switch(&state, AppType::Codex, "p2") - .expect("switch to official should succeed"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch to official should succeed"); let live_auth: Value = crate::config::read_json_file(&auth_path).expect("read auth.json"); assert_eq!( @@ -622,8 +635,13 @@ fn codex_switch_removes_empty_auth_json_for_openai_official_provider() { let state = state_from_config(config); - ProviderService::switch(&state, AppType::Codex, "codex-official") - .expect("switch to official should succeed without saved auth"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "codex-official", + prefer_incoming_conflicts(), + ) + .expect("switch to official should succeed without saved auth"); assert!( !auth_path.exists(), @@ -676,8 +694,20 @@ fn codex_switch_preserves_base_url_and_wire_api_across_multiple_switches() { // Seed initial live config for p1, then switch to p2, then back to p1. ProviderService::switch(&state, AppType::Codex, "p1").expect("seed p1 live"); - ProviderService::switch(&state, AppType::Codex, "p2").expect("switch to p2"); - ProviderService::switch(&state, AppType::Codex, "p1").expect("switch back to p1"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch to p2"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p1", + prefer_incoming_conflicts(), + ) + .expect("switch back to p1"); let live_text = std::fs::read_to_string(get_codex_config_path()).expect("read live config.toml"); @@ -776,7 +806,13 @@ trust_level = "trusted" ) .expect("seed live config.toml with runtime project trust"); - ProviderService::switch(&state, AppType::Codex, "p2").expect("switch to p2"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch to p2"); let cfg = state.config.read().expect("read config after switch"); let manager = cfg.get_manager(&AppType::Codex).expect("codex manager"); @@ -823,11 +859,17 @@ trust_level = "trusted" let p2_live = std::fs::read_to_string(get_codex_config_path()).expect("read p2 live config"); assert!( - !p2_live.contains("/tmp/codex-project-a"), - "target provider live config should not absorb source provider runtime project trust" + p2_live.contains("/tmp/codex-project-a"), + "target provider live config should preserve local runtime project trust during merge" ); - ProviderService::switch(&state, AppType::Codex, "p1").expect("switch back to p1"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p1", + prefer_incoming_conflicts(), + ) + .expect("switch back to p1"); let p1_live = std::fs::read_to_string(get_codex_config_path()).expect("read p1 live config"); assert!( p1_live.contains("[projects.\"/tmp/codex-project-a\"]"), @@ -883,7 +925,13 @@ fn codex_switch_backfill_migrates_existing_common_meta_for_current_provider() { ) .expect("seed live config.toml"); - ProviderService::switch(&state, AppType::Codex, "p2").expect("switch away from p1"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch away from p1"); { let cfg = state.config.read().expect("read config after switch"); @@ -909,7 +957,13 @@ fn codex_switch_backfill_migrates_existing_common_meta_for_current_provider() { ); } - ProviderService::switch(&state, AppType::Codex, "p1").expect("switch back to p1"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p1", + prefer_incoming_conflicts(), + ) + .expect("switch back to p1"); let live_config = std::fs::read_to_string(get_codex_config_path()).expect("read live config"); assert!( live_config.contains("disable_response_storage = true"), @@ -999,8 +1053,26 @@ async fn switch_updates_running_proxy_takeover_target_without_restart() { .and_then(Value::as_object) .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(Value::as_str), + Some("https://api.one.example"), + "hot-switch should preserve the original live backup used for restore" + ); + + let snapshot = state + .db + .get_failover_live_snapshot("claude", "p2") + .await + .expect("get failover snapshot") + .expect("failover snapshot should exist"); + let snapshot_value: Value = + serde_json::from_str(&snapshot.config_json).expect("parse failover snapshot"); + assert_eq!( + snapshot_value + .get("env") + .and_then(Value::as_object) + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(Value::as_str), Some("https://api.two.example"), - "hot-switch should also refresh the restore backup to the newly selected provider" + "hot-switch should generate a provider-specific live snapshot" ); state @@ -1522,7 +1594,7 @@ fn sync_current_to_live_prefers_effective_current_from_local_settings() { crate::settings::set_current_provider(&AppType::Claude, Some("p2")) .expect("set local effective current override"); - ProviderService::sync_current_to_live(&state) + ProviderService::sync_current_to_live_with_resolution(&state, prefer_incoming_conflicts()) .expect("sync_current_to_live should use effective current provider"); let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); @@ -1599,12 +1671,13 @@ fn updating_common_snippet_uses_db_current_without_fallback_healing_config() { &get_claude_settings_path(), &json!({ "env": { - "ANTHROPIC_AUTH_TOKEN": "stale-token", - "ANTHROPIC_BASE_URL": "https://stale.example" + "ANTHROPIC_AUTH_TOKEN": "token1", + "ANTHROPIC_BASE_URL": "https://claude.one", + "LOCAL_ONLY": "preserve-me" } }), ) - .expect("seed stale live settings"); + .expect("seed live settings"); let state = state_from_config(config); state @@ -1713,12 +1786,13 @@ fn updating_common_snippet_uses_db_current_when_config_snapshot_is_missing_curre &get_claude_settings_path(), &json!({ "env": { - "ANTHROPIC_AUTH_TOKEN": "stale-token", - "ANTHROPIC_BASE_URL": "https://stale.example" + "ANTHROPIC_AUTH_TOKEN": "token1", + "ANTHROPIC_BASE_URL": "https://claude.one", + "LOCAL_ONLY": "preserve-me" } }), ) - .expect("seed stale live settings"); + .expect("seed live settings"); let state = state_from_config(config); state @@ -2424,7 +2498,13 @@ fn provider_update_strips_common_snippet_before_claude_snapshot_persist() { None, )); - ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); + ProviderService::update_with_resolution( + &state, + AppType::Claude, + provider, + prefer_incoming_conflicts(), + ) + .expect("update should succeed"); let cfg = state.config.read().expect("read config after update"); let provider = cfg @@ -2507,7 +2587,13 @@ fn provider_update_does_not_infer_claude_common_config_opt_in() { None, ); - ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); + ProviderService::update_with_resolution( + &state, + AppType::Claude, + provider, + prefer_incoming_conflicts(), + ) + .expect("update should succeed"); let cfg = state.config.read().expect("read config after update"); let provider = cfg @@ -2607,7 +2693,13 @@ fn provider_update_treats_settings_effective_current_as_current_for_live_write() None, ); - ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); + ProviderService::update_with_resolution( + &state, + AppType::Claude, + provider, + prefer_incoming_conflicts(), + ) + .expect("update should succeed"); let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); let live_env = live @@ -2699,7 +2791,13 @@ fn provider_update_clears_invalid_local_current_override_and_falls_back_to_store None, ); - ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); + ProviderService::update_with_resolution( + &state, + AppType::Claude, + provider, + prefer_incoming_conflicts(), + ) + .expect("update should succeed"); assert_eq!( crate::settings::get_current_provider(&AppType::Claude), @@ -2765,7 +2863,13 @@ fn common_config_snippet_is_not_persisted_into_provider_snapshot_on_switch() { ProviderService::add(&state, AppType::Claude, p1).expect("add p1"); ProviderService::add(&state, AppType::Claude, p2).expect("add p2"); - ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); + ProviderService::switch_with_resolution( + &state, + AppType::Claude, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch to p2"); let cfg = state.config.read().expect("read config"); let manager = cfg.get_manager(&AppType::Claude).expect("claude manager"); @@ -2851,7 +2955,13 @@ fn switch_backfill_preserves_matching_common_fields_when_meta_missing() { ) .expect("seed live settings with provider-owned fields matching common snippet"); - ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); + ProviderService::switch_with_resolution( + &state, + AppType::Claude, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch to p2"); let cfg = state.config.read().expect("read config"); let manager = cfg.get_manager(&AppType::Claude).expect("claude manager"); @@ -3791,7 +3901,13 @@ base_url = "http://localhost:8080" } std::fs::write(&config_path, config_toml).expect("seed config.toml"); - ProviderService::switch(&state, AppType::Codex, "p2").expect("switch should succeed"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch should succeed"); let cfg = state.config.read().expect("read config after switch"); let extracted = cfg @@ -4106,7 +4222,13 @@ fn codex_switch_auto_extracted_common_normalizes_other_existing_provider_snapsho ) .expect("seed config.toml"); - ProviderService::switch(&state, AppType::Codex, "p2").expect("switch should succeed"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch should succeed"); let cfg = state.config.read().expect("read config after switch"); assert_eq!( @@ -4211,8 +4333,13 @@ fn codex_switch_auto_extracted_common_skips_unparseable_other_provider_snapshots ) .expect("seed config.toml"); - ProviderService::switch(&state, AppType::Codex, "p2") - .expect("switch should skip broken legacy snapshots"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch should skip broken legacy snapshots"); let cfg = state.config.read().expect("read config after switch"); assert_eq!( @@ -4290,7 +4417,13 @@ fn common_config_snippet_can_be_disabled_per_provider_for_codex() { let state = state_from_config(config); - ProviderService::switch(&state, AppType::Codex, "p2").expect("switch should succeed"); + ProviderService::switch_with_resolution( + &state, + AppType::Codex, + "p2", + prefer_incoming_conflicts(), + ) + .expect("switch should succeed"); let live_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml"); assert!( @@ -5051,6 +5184,99 @@ fn common_config_snippet_is_not_persisted_into_gemini_provider_snapshot_on_switc ); } +#[test] +#[serial] +fn switching_google_official_gemini_clears_stale_api_key_env() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + std::fs::create_dir_all(crate::gemini_config::get_gemini_dir()) + .expect("create ~/.gemini (initialized)"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Gemini); + { + let manager = config + .get_manager_mut(&AppType::Gemini) + .expect("gemini manager"); + manager.current = "api-key".to_string(); + manager.providers.insert( + "api-key".to_string(), + Provider::with_id( + "api-key".to_string(), + "API Key".to_string(), + json!({ + "env": { + "GEMINI_API_KEY": "token1", + "GOOGLE_GEMINI_BASE_URL": "https://api.example.com", + "GEMINI_BASE_URL": "https://legacy.example.com", + "GEMINI_MODEL": "gemini-test" + } + }), + None, + ), + ); + let mut google = Provider::with_id( + "google-official".to_string(), + "Google".to_string(), + json!({ "env": {} }), + Some("https://ai.google.dev".to_string()), + ); + google.meta = Some(crate::provider::ProviderMeta { + partner_promotion_key: Some("google-official".to_string()), + ..crate::provider::ProviderMeta::default() + }); + manager + .providers + .insert("google-official".to_string(), google); + } + + crate::gemini_config::write_gemini_env_atomic(&std::collections::HashMap::from([ + ("GEMINI_API_KEY".to_string(), "token1".to_string()), + ( + "GOOGLE_GEMINI_BASE_URL".to_string(), + "https://api.example.com".to_string(), + ), + ( + "GEMINI_BASE_URL".to_string(), + "https://legacy.example.com".to_string(), + ), + ("GEMINI_MODEL".to_string(), "gemini-test".to_string()), + ("USER_DEFINED_ENV".to_string(), "keep-me".to_string()), + ])) + .expect("seed current gemini env"); + + let state = state_from_config(config); + ProviderService::switch(&state, AppType::Gemini, "google-official") + .expect("switch to Google official Gemini"); + + let live_env = crate::gemini_config::read_gemini_env().expect("read gemini env"); + for key in [ + "GEMINI_API_KEY", + "GOOGLE_GEMINI_BASE_URL", + "GEMINI_BASE_URL", + "GEMINI_MODEL", + ] { + assert!( + !live_env.contains_key(key), + "Google official Gemini should clear stale {key} from .env" + ); + } + assert_eq!( + live_env.get("USER_DEFINED_ENV").map(String::as_str), + Some("keep-me"), + "unrelated local Gemini env keys should be preserved" + ); + + let settings: Value = read_json_file(&crate::gemini_config::get_gemini_settings_path()) + .expect("read gemini settings"); + assert_eq!( + settings + .pointer("/security/auth/selectedType") + .and_then(Value::as_str), + Some("oauth-personal") + ); +} + #[test] #[serial] fn updating_common_snippet_removes_stale_fields_from_other_gemini_provider_snapshots() { diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 7a03d8d5..547c5284 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -129,7 +129,7 @@ impl ProviderService { } let (api_key, base_url) = - Self::resolve_usage_script_credentials(&provider, &app_type, usage_script)?; + Self::resolve_usage_script_credentials(provider, &app_type, usage_script)?; ( usage_script.code.clone(), diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 14151c07..86a1f980 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -25,6 +25,7 @@ use crate::{ types::{ActiveTarget, GlobalProxyConfig, ProxyTakeoverStatus}, ProxyConfig, ProxyServer, ProxyServerInfo, ProxyStatus, }, + services::provider::live_merge, AppError, }; @@ -80,18 +81,14 @@ pub struct GlobalProxySwitchUpdate { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +#[derive(Default)] enum PersistedProxyRuntimeSessionKind { #[serde(alias = "foreground")] + #[default] Foreground, ManagedExternal, } -impl Default for PersistedProxyRuntimeSessionKind { - fn default() -> Self { - Self::Foreground - } -} - impl PersistedProxyRuntimeSessionKind { fn from_env() -> Self { match std::env::var(PROXY_RUNTIME_KIND_ENV_KEY).ok().as_deref() { @@ -143,7 +140,7 @@ pub(crate) struct LiveManagedRuntimeSession { } enum ExternalProxyStatusProbe { - Matched(ProxyStatus), + Matched(Box), Mismatched, Unreachable, } @@ -727,14 +724,14 @@ impl ProxyService { self.sync_persisted_global_proxy_enabled(false).await?; } if let Some(session) = self.load_persisted_runtime_session_for_app(&app) { - if session.kind.is_managed_external() { - if matches!( + if session.kind.is_managed_external() + && matches!( Self::probe_external_proxy_status(&session).await, ExternalProxyStatusProbe::Matched(_) - ) && Self::is_process_alive(session.pid) - { - Self::terminate_external_process(session.pid).await?; - } + ) + && Self::is_process_alive(session.pid) + { + Self::terminate_external_process(session.pid).await?; } } let _ = self.clear_persisted_runtime_session_for_app(&app); @@ -910,6 +907,8 @@ impl ProxyService { if !app_proxy.enabled && !has_backup && !live_taken_over { self.clear_app_proxy_routing_flags(app_proxy).await?; + self.delete_failover_live_snapshots_for_app(&app_type) + .await?; continue; } @@ -933,6 +932,8 @@ impl ProxyService { } self.clear_app_proxy_routing_flags(app_proxy).await?; + self.delete_failover_live_snapshots_for_app(&app_type) + .await?; } Ok(()) @@ -994,15 +995,15 @@ impl ProxyService { } if let Some(session) = self.load_persisted_runtime_session() { - if session.kind.is_managed_external() { - if matches!( + if session.kind.is_managed_external() + && matches!( Self::probe_external_proxy_status(&session).await, ExternalProxyStatusProbe::Matched(_) - ) && Self::is_process_alive(session.pid) - { - Self::terminate_external_process(session.pid).await?; - stopped_runtime = true; - } + ) + && Self::is_process_alive(session.pid) + { + Self::terminate_external_process(session.pid).await?; + stopped_runtime = true; } } @@ -1063,7 +1064,8 @@ impl ProxyService { } match Self::probe_external_proxy_status(&session).await { - ExternalProxyStatusProbe::Matched(mut status) => { + ExternalProxyStatusProbe::Matched(status) => { + let mut status = *status; workers.push(crate::proxy::types::ActiveWorker { app_type: session .app_type @@ -1507,6 +1509,9 @@ impl ProxyService { self.db.clear_auto_failover_for_supported_apps().await? }; self.db.update_global_proxy_config(config.clone()).await?; + if !enabled { + self.db.delete_all_failover_live_snapshots().await?; + } Ok(GlobalProxySwitchUpdate { config, @@ -1523,6 +1528,204 @@ impl ProxyService { .ok_or_else(|| "failover queue is empty".to_string()) } + fn failover_queue_providers(&self, app_type: &AppType) -> Result, String> { + let app_key = app_type.as_str(); + self.db + .get_failover_queue(app_key) + .map_err(|error| format!("load failover queue for {app_key} failed: {error}"))? + .into_iter() + .map(|item| { + self.db + .get_provider_by_id(&item.provider_id, app_key) + .map_err(|error| { + format!( + "load failover provider {} for {app_key} failed: {error}", + item.provider_id + ) + })? + .ok_or_else(|| { + format!("failover provider does not exist: {}", item.provider_id) + }) + }) + .collect() + } + + async fn original_failover_live_base( + &self, + app_type: &AppType, + fallback_provider_id: Option<&str>, + ) -> Result { + let app_key = app_type.as_str(); + if let Some(existing) = self.load_live_backup_value(app_type).await? { + return Ok(existing); + } + + let (live, sync_live_token_to_current, _) = self + .read_takeover_source_live(app_type, fallback_provider_id) + .await?; + let backup = serde_json::to_string(&live) + .map_err(|error| format!("serialize {app_key} live backup failed: {error}"))?; + self.db + .save_live_backup(app_key, &backup) + .await + .map_err(|error| format!("save {app_key} live backup failed: {error}"))?; + if sync_live_token_to_current { + self.sync_live_config_to_current_provider(app_type, &live) + .await?; + } + + Ok(live) + } + + fn build_failover_live_snapshot( + &self, + app_type: &AppType, + original_live: &Value, + provider: &Provider, + ) -> Result { + let provider_snapshot = self.build_live_snapshot_from_provider(app_type, provider)?; + Self::merge_live_backup_snapshot( + app_type, + Some(original_live), + provider_snapshot, + live_merge::ConflictPolicy::PreferIncoming.into(), + ) + } + + async fn save_failover_live_snapshot( + &self, + app_type: &AppType, + provider_id: &str, + snapshot: &Value, + ) -> Result<(), String> { + let app_key = app_type.as_str(); + let serialized = serde_json::to_string(snapshot).map_err(|error| { + format!("serialize {app_key} failover live snapshot for {provider_id} failed: {error}") + })?; + self.db + .save_failover_live_snapshot(app_key, provider_id, &serialized) + .await + .map_err(|error| { + format!("save {app_key} failover live snapshot for {provider_id} failed: {error}") + }) + } + + async fn regenerate_failover_live_snapshots_for_app( + &self, + app_type: &AppType, + fallback_provider_id: Option<&str>, + ) -> Result<(), String> { + let app_key = app_type.as_str(); + let providers = self.failover_queue_providers(app_type)?; + let original_live = self + .original_failover_live_base(app_type, fallback_provider_id) + .await?; + self.db + .delete_failover_live_snapshots_for_app(app_key) + .await + .map_err(|error| format!("clear {app_key} failover live snapshots failed: {error}"))?; + + for provider in providers { + let snapshot = + self.build_failover_live_snapshot(app_type, &original_live, &provider)?; + self.save_failover_live_snapshot(app_type, &provider.id, &snapshot) + .await?; + } + + Ok(()) + } + + async fn failover_live_snapshot_for_provider( + &self, + app_type: &AppType, + provider: &Provider, + ) -> Result { + let app_key = app_type.as_str(); + if let Some(snapshot) = self + .db + .get_failover_live_snapshot(app_key, &provider.id) + .await + .map_err(|error| { + format!( + "load {app_key} failover live snapshot for {} failed: {error}", + provider.id + ) + })? + { + return serde_json::from_str(&snapshot.config_json).map_err(|error| { + format!( + "parse {app_key} failover live snapshot for {} failed: {error}", + provider.id + ) + }); + } + + let original_live = self + .original_failover_live_base(app_type, Some(&provider.id)) + .await?; + let snapshot = self.build_failover_live_snapshot(app_type, &original_live, provider)?; + self.save_failover_live_snapshot(app_type, &provider.id, &snapshot) + .await?; + Ok(snapshot) + } + + async fn write_failover_live_snapshot_for_provider( + &self, + app_type: &AppType, + provider: &Provider, + ) -> Result<(), String> { + let mut live = self + .failover_live_snapshot_for_provider(app_type, provider) + .await?; + let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls_for_app(app_type).await?; + self.rewrite_live_for_proxy( + app_type, + &mut live, + &proxy_url, + &proxy_codex_base_url, + Some(provider), + )?; + if matches!(app_type, AppType::Codex) { + self.write_codex_live_for_provider(&live, Some(provider)) + } else { + self.write_live_config_for_app(app_type, &live) + } + } + + pub async fn refresh_failover_live_snapshot_for_provider( + &self, + app_type: &str, + provider: &Provider, + ) -> Result<(), String> { + let app_type = Self::takeover_app_from_str(app_type)?; + let original_live = self + .original_failover_live_base(&app_type, Some(&provider.id)) + .await?; + let snapshot = self.build_failover_live_snapshot(&app_type, &original_live, provider)?; + self.save_failover_live_snapshot(&app_type, &provider.id, &snapshot) + .await?; + + if self.detect_takeover_in_live_config_for_app(&app_type) { + self.write_failover_live_snapshot_for_provider(&app_type, provider) + .await?; + } + + Ok(()) + } + + async fn delete_failover_live_snapshots_for_app( + &self, + app_type: &AppType, + ) -> Result<(), String> { + let app_key = app_type.as_str(); + self.db + .delete_failover_live_snapshots_for_app(app_key) + .await + .map_err(|error| { + format!("delete failover live snapshots for {app_key} failed: {error}") + }) + } + async fn persist_auto_failover_for_app( &self, app_type: &str, @@ -1598,10 +1801,14 @@ impl ProxyService { pub async fn enable_auto_failover_for_app(&self, app_type: &str) -> Result<(), String> { let first_provider_id = self.first_failover_provider_id(app_type)?; - self.ensure_proxy_routing_active_for_app(app_type).await?; - self.switch_proxy_target(app_type, &first_provider_id) + let app_type = Self::takeover_app_from_str(app_type)?; + let app_key = app_type.as_str(); + self.ensure_proxy_routing_active_for_app(app_key).await?; + self.regenerate_failover_live_snapshots_for_app(&app_type, Some(&first_provider_id)) .await?; - self.persist_auto_failover_for_app(app_type, true).await + self.switch_proxy_target(app_key, &first_provider_id) + .await?; + self.persist_auto_failover_for_app(app_key, true).await } async fn prepare_proxy_and_auto_failover_activation( @@ -1611,6 +1818,8 @@ impl ProxyService { let first_provider_id = self.first_failover_provider_id(app_type)?; let app_type = Self::takeover_app_from_str(app_type)?; let app_key = app_type.as_str(); + self.regenerate_failover_live_snapshots_for_app(&app_type, Some(&first_provider_id)) + .await?; self.switch_proxy_target(app_key, &first_provider_id) .await?; Ok(app_type) @@ -1764,46 +1973,161 @@ impl ProxyService { app_type: &str, provider: &Provider, ) -> Result<(), String> { + let app_type_enum = Self::takeover_app_from_str(app_type)?; + let backup_snapshot = self.build_live_snapshot_from_provider(&app_type_enum, provider)?; + let existing_backup_value = self.load_live_backup_value(&app_type_enum).await?; + let backup_snapshot = Self::merge_live_backup_snapshot( + &app_type_enum, + existing_backup_value.as_ref(), + backup_snapshot, + live_merge::ConflictPolicy::PreferIncoming.into(), + )?; + self.save_live_backup_snapshot(app_type, &backup_snapshot) + .await + } + + pub(crate) async fn prepare_live_backup_from_provider_with_resolution( + &self, + app_type: &str, + provider: &Provider, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { let app_type = Self::takeover_app_from_str(app_type)?; - let mut backup_snapshot = self.build_live_snapshot_from_provider(&app_type, provider)?; + let backup_snapshot = self.build_live_snapshot_from_provider(&app_type, provider)?; + let existing_backup_value = self.load_live_backup_value(&app_type).await?; + Self::merge_live_backup_snapshot( + &app_type, + existing_backup_value.as_ref(), + backup_snapshot, + resolution, + ) + } - if matches!(app_type, AppType::Codex) { - let existing_backup_value = self - .db - .get_live_backup(app_type.as_str()) - .await - .map_err(|error| { + async fn load_live_backup_value(&self, app_type: &AppType) -> Result, String> { + self.db + .get_live_backup(app_type.as_str()) + .await + .map_err(|error| { + format!( + "load {} existing live backup failed: {error}", + app_type.as_str() + ) + })? + .map(|backup| { + serde_json::from_str::(&backup.original_config).map_err(|error| { format!( - "load {} existing live backup failed: {error}", + "parse {} existing live backup failed: {error}", app_type.as_str() ) - })? - .map(|backup| { - serde_json::from_str::(&backup.original_config).map_err(|error| { - format!( - "parse {} existing live backup failed: {error}", - app_type.as_str() - ) - }) }) - .transpose()?; + }) + .transpose() + } - if let Some(existing_value) = existing_backup_value.as_ref() { - Self::preserve_codex_mcp_servers_in_backup(&mut backup_snapshot, existing_value)?; + fn merge_live_backup_snapshot( + app_type: &AppType, + existing_backup: Option<&Value>, + backup_snapshot: Value, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { + match app_type { + AppType::Claude => match existing_backup { + Some(existing) => live_merge::merge_json_live( + app_type, + "proxy live backup", + existing.clone(), + &backup_snapshot, + resolution, + ) + .map_err(|error| error.to_string()), + None => Ok(backup_snapshot), + }, + AppType::Codex => { + Self::merge_codex_live_backup(existing_backup, backup_snapshot, resolution) } - } - - if matches!(app_type, AppType::Gemini) { - backup_snapshot = json!({ - "env": backup_snapshot + AppType::Gemini => { + let incoming_env = backup_snapshot .get("env") .cloned() - .unwrap_or_else(|| json!({})) - }); + .unwrap_or_else(|| json!({})); + let incoming_snapshot = json!({ "env": incoming_env }); + match existing_backup { + Some(existing) => live_merge::merge_json_live( + app_type, + "proxy live backup", + existing.clone(), + &incoming_snapshot, + resolution, + ) + .map_err(|error| error.to_string()), + None => Ok(incoming_snapshot), + } + } + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(backup_snapshot), } + } - self.save_live_backup_snapshot(app_type.as_str(), &backup_snapshot) - .await + fn merge_codex_live_backup( + existing_backup: Option<&Value>, + mut incoming_backup: Value, + resolution: live_merge::ConflictResolution<'_>, + ) -> Result { + let Some(existing_backup) = existing_backup else { + return Ok(incoming_backup); + }; + + let mut merged = existing_backup.clone(); + let existing_auth = existing_backup + .get("auth") + .cloned() + .unwrap_or_else(|| json!({})); + let incoming_auth = incoming_backup + .get("auth") + .cloned() + .unwrap_or_else(|| json!({})); + let merged_auth = live_merge::merge_json_live( + &AppType::Codex, + "proxy live backup auth", + existing_auth, + &incoming_auth, + resolution, + ) + .map_err(|error| error.to_string())?; + + let existing_config = existing_backup + .get("config") + .and_then(Value::as_str) + .unwrap_or_default(); + let incoming_config = incoming_backup + .get("config") + .and_then(Value::as_str) + .unwrap_or_default(); + let merged_config = live_merge::merge_toml_live( + &AppType::Codex, + "proxy live backup config", + existing_config, + incoming_config, + resolution, + ) + .map_err(|error| error.to_string())?; + + if let Some(root) = merged.as_object_mut() { + root.insert("auth".to_string(), merged_auth); + root.insert("config".to_string(), json!(merged_config)); + if let Some(incoming_root) = incoming_backup.as_object_mut() { + for (key, value) in incoming_root { + if key != "auth" && key != "config" && !root.contains_key(key) { + root.insert(key.clone(), value.clone()); + } + } + } + Ok(merged) + } else { + let mut merged_root = serde_json::Map::new(); + merged_root.insert("auth".to_string(), merged_auth); + merged_root.insert("config".to_string(), json!(merged_config)); + Ok(Value::Object(merged_root)) + } } pub async fn hot_switch_provider( @@ -1833,7 +2157,7 @@ impl ProxyService { .map_err(|e| format!("读取 {app_type} 备份失败: {e}"))? .is_some(); let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum); - let should_sync_backup = has_backup || live_taken_over; + let should_sync_live = has_backup || live_taken_over; self.db .set_current_provider(app_type_enum.as_str(), provider_id) @@ -1841,14 +2165,9 @@ impl ProxyService { crate::settings::set_current_provider(&app_type_enum, Some(provider_id)) .map_err(|e| format!("更新本地当前供应商失败: {e}"))?; - if should_sync_backup { - self.update_live_backup_from_provider(app_type, &provider) + if should_sync_live { + self.write_failover_live_snapshot_for_provider(&app_type_enum, &provider) .await?; - - if matches!(app_type_enum, AppType::Claude) { - self.sync_claude_live_from_provider_while_proxy_active(&provider) - .await?; - } } if let Some(server) = self.runtime.server.read().await.as_ref() { @@ -1896,63 +2215,6 @@ impl ProxyService { Ok(()) } - fn preserve_codex_mcp_servers_in_backup( - target_settings: &mut Value, - existing_backup: &Value, - ) -> Result<(), String> { - let target_obj = target_settings - .as_object_mut() - .ok_or_else(|| "Codex live backup must be a JSON object".to_string())?; - - let target_config = target_obj - .get("config") - .and_then(Value::as_str) - .unwrap_or(""); - let mut target_doc = if target_config.trim().is_empty() { - toml_edit::DocumentMut::new() - } else { - target_config - .parse::() - .map_err(|error| format!("parse new Codex config.toml failed: {error}"))? - }; - - let existing_config = existing_backup - .get("config") - .and_then(Value::as_str) - .unwrap_or(""); - if existing_config.trim().is_empty() { - target_obj.insert("config".to_string(), json!(target_doc.to_string())); - return Ok(()); - } - - let existing_doc = existing_config - .parse::() - .map_err(|error| format!("parse existing Codex backup failed: {error}"))?; - - if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") { - match target_doc.get_mut("mcp_servers") { - Some(target_mcp_servers) => { - if let (Some(target_table), Some(existing_table)) = ( - target_mcp_servers.as_table_like_mut(), - existing_mcp_servers.as_table_like(), - ) { - for (server_id, server_item) in existing_table.iter() { - if target_table.get(server_id).is_none() { - target_table.insert(server_id, server_item.clone()); - } - } - } - } - None => { - target_doc["mcp_servers"] = existing_mcp_servers.clone(); - } - } - } - - target_obj.insert("config".to_string(), json!(target_doc.to_string())); - Ok(()) - } - pub(crate) async fn validate_app_proxy_activation( &self, app_type: &AppType, @@ -2082,7 +2344,10 @@ impl ProxyService { self.validate_app_proxy_activation(app_type, fallback_provider_id) .await?; - if !runtime_already_known && !self.is_running().await { + if !runtime_already_known + && !self.has_running_foreground_runtime().await + && !self.has_managed_worker_for_app(app_type).await + { let config = self.runtime_config_for_app(app_type).await?; self.start_with_resolved_config_unlocked(config).await?; } @@ -2169,6 +2434,8 @@ impl ProxyService { if !app_proxy.enabled && !has_backup && !live_taken_over { self.clear_app_proxy_routing_flags(app_proxy).await?; + self.delete_failover_live_snapshots_for_app(app_type) + .await?; return Ok(()); } @@ -2183,6 +2450,8 @@ impl ProxyService { } self.clear_app_proxy_routing_flags(app_proxy).await?; + self.delete_failover_live_snapshots_for_app(app_type) + .await?; self.db .clear_provider_health_for_app(app_key) @@ -2860,10 +3129,10 @@ impl ProxyService { .ok_or_else(|| "Codex config missing auth field".to_string())?; let config_text = config.get("config").and_then(Value::as_str); let mut settings = config.clone(); - if !settings + if settings .get("modelCatalog") .and_then(|catalog| catalog.get("models")) - .is_some() + .is_none() { if let Some(root) = settings.as_object_mut() { root.insert( @@ -2896,10 +3165,10 @@ impl ProxyService { .ok_or_else(|| "Codex config missing auth field".to_string())?; let config_text = config.get("config").and_then(Value::as_str); let mut settings = config.clone(); - if !settings + if settings .get("modelCatalog") .and_then(|catalog| catalog.get("models")) - .is_some() + .is_none() { if let Some(root) = settings.as_object_mut() { root.insert( @@ -3194,7 +3463,7 @@ impl ProxyService { #[cfg(unix)] { let rc = unsafe { libc::kill(pid as i32, 0) }; - return rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM); + rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } #[cfg(not(unix))] @@ -3297,7 +3566,7 @@ impl ProxyService { if status.port == 0 { status.port = session.port; } - ExternalProxyStatusProbe::Matched(status) + ExternalProxyStatusProbe::Matched(Box::new(status)) } fn build_session_status_url(session: &PersistedProxyRuntimeSession) -> String { @@ -3354,10 +3623,10 @@ impl ProxyService { tokio::time::sleep(Duration::from_millis(50)).await; } - return Err(format!( + Err(format!( "managed proxy session did not exit after termination signal: pid {}", pid - )); + )) } #[cfg(not(unix))] @@ -3787,6 +4056,10 @@ mod tests { Response, TakeoverFlags, WorkerRuntimeStatus, WorkerState, WorkerTargetState, }; + #[expect( + clippy::too_many_arguments, + reason = "test fixture builder mirrors worker status fields" + )] fn worker( app_type: &str, port: u16, @@ -4104,7 +4377,7 @@ mod tests { #[tokio::test] #[serial] - async fn enable_auto_failover_for_app_switches_to_queue_head() { + async fn enable_auto_failover_for_app_switches_to_queue_head_and_generates_snapshots() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestHomeEnvGuard::set(temp_home.path()); @@ -4124,10 +4397,19 @@ mod tests { None, ); provider_b.sort_index = Some(1); + let mut provider_c = Provider::with_id( + "provider-c".to_string(), + "Provider C".to_string(), + json!({"env":{"ANTHROPIC_BASE_URL":"https://c.example","ANTHROPIC_AUTH_TOKEN":"c"}}), + None, + ); + provider_c.sort_index = Some(3); db.save_provider("claude", &provider_a) .expect("save provider a"); db.save_provider("claude", &provider_b) .expect("save provider b"); + db.save_provider("claude", &provider_c) + .expect("save provider c"); db.set_current_provider("claude", &provider_a.id) .expect("set current provider"); crate::settings::set_current_provider(&AppType::Claude, Some(&provider_a.id)) @@ -4136,6 +4418,18 @@ mod tests { .expect("queue provider b"); db.add_to_failover_queue("claude", &provider_a.id) .expect("queue provider a"); + db.add_to_failover_queue("claude", &provider_c.id) + .expect("queue provider c"); + let original_live = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://local.example", + "ANTHROPIC_AUTH_TOKEN": "local", + "LOCAL_ONLY": "kept" + } + }); + db.save_live_backup("claude", &original_live.to_string()) + .await + .expect("seed original live backup"); let (_server, port) = spawn_status_server_for_test("claude-test-token").await; seed_managed_worker_for_app(db.as_ref(), "claude", port); @@ -4161,6 +4455,45 @@ mod tests { .as_deref(), Some("provider-b") ); + + let backup = db + .get_live_backup("claude") + .await + .expect("get original live backup") + .expect("backup exists"); + let stored_backup: Value = + serde_json::from_str(&backup.original_config).expect("parse original live backup"); + assert_eq!(stored_backup, original_live); + + for (provider_id, token, base_url) in [ + ("provider-a", "a", "https://a.example"), + ("provider-b", "b", "https://b.example"), + ("provider-c", "c", "https://c.example"), + ] { + let snapshot = db + .get_failover_live_snapshot("claude", provider_id) + .await + .expect("get failover snapshot") + .unwrap_or_else(|| panic!("snapshot exists for {provider_id}")); + let stored: Value = + serde_json::from_str(&snapshot.config_json).expect("parse failover snapshot"); + assert_eq!( + stored + .pointer("/env/ANTHROPIC_AUTH_TOKEN") + .and_then(Value::as_str), + Some(token) + ); + assert_eq!( + stored + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(Value::as_str), + Some(base_url) + ); + assert_eq!( + stored.pointer("/env/LOCAL_ONLY").and_then(Value::as_str), + Some("kept") + ); + } } #[tokio::test] @@ -6509,7 +6842,7 @@ wire_api = "responses" #[tokio::test] #[serial] - async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() { + async fn hot_switch_codex_provider_uses_failover_snapshot_without_mutating_live_backup() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestHomeEnvGuard::set(temp_home.path()); @@ -6563,9 +6896,10 @@ requires_openai_auth = true .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("a")) .expect("set local current provider"); + let original_backup = provider_a.settings_config.clone(); db.save_live_backup( "codex", - &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"), + &serde_json::to_string(&original_backup).expect("serialize provider a"), ) .await .expect("seed live backup"); @@ -6596,25 +6930,43 @@ requires_openai_auth = true .await .expect("get live backup") .expect("backup exists"); - let stored: Value = + let stored_backup: Value = serde_json::from_str(&backup.original_config).expect("parse backup json"); - let backup_config = stored + assert_eq!(stored_backup, original_backup); + + let snapshot = db + .get_failover_live_snapshot("codex", "b") + .await + .expect("get Codex failover snapshot") + .expect("snapshot exists"); + let stored: Value = + serde_json::from_str(&snapshot.config_json).expect("parse snapshot json"); + assert_eq!( + stored + .get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY")) + .and_then(Value::as_str), + Some("aihubmix-key") + ); + let snapshot_config = stored .get("config") .and_then(Value::as_str) - .expect("backup config string"); - let parsed_backup: toml::Value = - toml::from_str(backup_config).expect("parse backup config"); + .expect("snapshot config string"); + let parsed_snapshot: toml::Value = + toml::from_str(snapshot_config).expect("parse snapshot config"); assert_eq!( - parsed_backup.get("model_provider").and_then(|v| v.as_str()), + parsed_snapshot + .get("model_provider") + .and_then(|v| v.as_str()), Some("aihubmix"), - "provider-derived restore backup should preserve the selected provider template" + "provider-derived snapshot should preserve the selected provider template" ); - let backup_model_providers = parsed_backup + let snapshot_model_providers = parsed_snapshot .get("model_providers") .and_then(|v| v.as_table()) - .expect("backup model_providers"); + .expect("snapshot model_providers"); assert_eq!( - backup_model_providers + snapshot_model_providers .get("aihubmix") .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), @@ -6622,11 +6974,6 @@ requires_openai_auth = true "selected provider id should point at the hot-switched provider endpoint" ); - service - .restore_live_config_for_app(&AppType::Codex) - .await - .expect("restore Codex live config"); - let live = service.read_codex_live().expect("read Codex live config"); let live_config = live .get("config") @@ -6636,14 +6983,56 @@ requires_openai_auth = true assert_eq!( parsed_live.get("model_provider").and_then(|v| v.as_str()), Some("aihubmix"), - "restored Codex live config should preserve the hot-switched provider template" + "active Codex live config should use the hot-switched provider snapshot" ); assert_eq!( live.get("auth") .and_then(|auth| auth.get("OPENAI_API_KEY")) .and_then(Value::as_str), - Some("aihubmix-key"), - "restore should still use the hot-switched provider auth" + Some(PROXY_TOKEN_PLACEHOLDER), + "active live auth should be proxy-managed during takeover" + ); + let live_model_providers = parsed_live + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("live model_providers"); + assert!( + live_model_providers + .get("aihubmix") + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()) + .is_some_and(crate::services::proxy::codex_toml::is_loopback_proxy_url), + "active live provider endpoint should be rewritten to the local proxy" + ); + + service + .restore_live_config_for_app(&AppType::Codex) + .await + .expect("restore Codex live config"); + + let restored = service + .read_codex_live() + .expect("read restored Codex live config"); + let restored_config = restored + .get("config") + .and_then(Value::as_str) + .expect("restored config string"); + let parsed_restored: toml::Value = + toml::from_str(restored_config).expect("parse restored config"); + assert_eq!( + parsed_restored + .get("model_provider") + .and_then(|v| v.as_str()), + Some("rightcode"), + "restore should use the original local backup" + ); + assert_eq!( + restored + .get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY")) + .and_then(Value::as_str), + Some("rightcode-key"), + "restore should use the original local backup auth" ); } @@ -6826,11 +7215,22 @@ requires_openai_auth = true #[tokio::test] #[serial] - async fn switch_proxy_target_updates_live_backup_when_taken_over() { + async fn switch_proxy_target_uses_failover_snapshot_without_mutating_live_backup() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestHomeEnvGuard::set(temp_home.path()); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("reserve free local port"); + let preferred_port = listener + .local_addr() + .expect("read reserved listener address") + .port(); + drop(listener); + let db = Arc::new(Database::memory().expect("init db")); + db.set_app_proxy_preferred_port("claude", preferred_port) + .expect("persist claude preferred proxy port"); let service = ProxyService::new(db.clone()); let provider_a = Provider::with_id( @@ -6859,8 +7259,11 @@ requires_openai_auth = true .expect("save provider b"); db.set_current_provider("claude", "a") .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Claude, Some("a")) + .expect("set settings current provider"); - db.save_live_backup("claude", "{\"env\":{}}") + let original_live = json!({"env":{"LOCAL_ONLY":"kept"}}); + db.save_live_backup("claude", &original_live.to_string()) .await .expect("seed live backup"); @@ -6879,8 +7282,39 @@ requires_openai_auth = true .await .expect("get live backup") .expect("backup exists"); - let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize"); - assert_eq!(backup.original_config, expected); + let stored_backup: Value = + serde_json::from_str(&backup.original_config).expect("parse live backup"); + assert_eq!(stored_backup, original_live); + + let snapshot = db + .get_failover_live_snapshot("claude", "b") + .await + .expect("get failover snapshot") + .expect("snapshot exists"); + let stored_snapshot: Value = + serde_json::from_str(&snapshot.config_json).expect("parse failover snapshot"); + assert_eq!( + stored_snapshot + .pointer("/env/ANTHROPIC_API_KEY") + .and_then(Value::as_str), + Some("b-key") + ); + assert_eq!( + stored_snapshot + .pointer("/env/LOCAL_ONLY") + .and_then(Value::as_str), + Some("kept") + ); + + let live = service.read_claude_live().expect("read Claude live config"); + let env = env_object(&live); + assert_env_str( + env, + "ANTHROPIC_BASE_URL", + Some(format!("http://127.0.0.1:{preferred_port}").as_str()), + ); + assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); + assert_env_str(env, "LOCAL_ONLY", Some("kept")); } #[tokio::test] diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index 4bc28a7d..6c47b93e 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -38,6 +38,7 @@ pub struct SessionSyncResult { } /// 数据来源分布 +#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DataSourceSummary { @@ -616,6 +617,7 @@ fn find_model_pricing_for_session( } /// 查询数据来源分布统计 +#[allow(dead_code)] pub fn get_data_source_breakdown(db: &Database) -> Result, AppError> { let conn = lock_conn!(db.conn); diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 35192b2d..5dfa6612 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -562,7 +562,6 @@ impl SkillService { // Prefer looking in apps where this skill is enabled; fallback to all apps. let mut candidates: Vec = Self::supported_skill_apps() - .into_iter() .filter(|app| record.apps.is_enabled_for(app)) .collect(); if candidates.is_empty() { diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 8d92d9e2..40bb5848 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -688,6 +688,10 @@ fn gemini_oauth_client_from_options( }) } +#[expect( + dead_code, + reason = "kept for Gemini OAuth clients loaded from JSON credentials" +)] fn gemini_oauth_client_from_json(value: &serde_json::Value) -> Option { let client_id = value .get("client_id") diff --git a/src-tauri/src/services/visible_apps.rs b/src-tauri/src/services/visible_apps.rs index f94b5257..bb664e14 100644 --- a/src-tauri/src/services/visible_apps.rs +++ b/src-tauri/src/services/visible_apps.rs @@ -229,8 +229,7 @@ fn detection_would_change(apps: &VisibleApps, detection: &VisibleAppsDetection) fn detection_string_map(detection: &VisibleAppsDetection) -> HashMap { CONTROLLED_APPS .iter() - .cloned() - .map(|app| (app.as_str().to_string(), detection.is_installed(&app))) + .map(|app| (app.as_str().to_string(), detection.is_installed(app))) .collect() } diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 0288ae98..1c7b041d 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -253,6 +253,10 @@ impl AppState { persist_multi_app_config_to_db(&self.db, &config) } + pub(crate) fn save_config_snapshot(&self, config: &MultiAppConfig) -> Result<(), AppError> { + persist_multi_app_config_to_db(&self.db, config) + } + /// 将内存中的 config 快照持久化到 SQLite,但保留指定应用当前供应商的 DB 选择。 pub fn save_preserving_current_providers( &self, @@ -262,6 +266,14 @@ impl AppState { persist_multi_app_config_to_db_preserving_current_providers(&self.db, &config, app_types) } + pub(crate) fn save_config_snapshot_preserving_current_providers( + &self, + config: &MultiAppConfig, + app_types: &[crate::app_config::AppType], + ) -> Result<(), AppError> { + persist_multi_app_config_to_db_preserving_current_providers(&self.db, config, app_types) + } + /// 用数据库中的最新快照重建内存配置,供导入/恢复后的 live 同步流程复用。 pub fn refresh_config_from_db(&self) -> Result<(), AppError> { let mut config = export_db_to_multi_app_config(&self.db)?; diff --git a/src-tauri/src/test_support.rs b/src-tauri/src/test_support.rs index b5b337b2..a72a1748 100644 --- a/src-tauri/src/test_support.rs +++ b/src-tauri/src/test_support.rs @@ -39,6 +39,7 @@ pub(crate) struct TestEnvGuard { old_cc_switch_config_dir: Option, old_claude_config_dir: Option, old_codex_home: Option, + old_xdg_runtime_dir: Option, } impl TestEnvGuard { @@ -49,12 +50,14 @@ impl TestEnvGuard { let old_cc_switch_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR"); let old_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR"); let old_codex_home = std::env::var_os("CODEX_HOME"); + let old_xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR"); std::env::set_var("HOME", home); std::env::set_var("USERPROFILE", home); std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch")); std::env::set_var("CLAUDE_CONFIG_DIR", home.join(".claude")); std::env::set_var("CODEX_HOME", home.join(".codex")); + std::env::set_var("XDG_RUNTIME_DIR", home.join(".runtime")); set_test_home_override(Some(home)); crate::settings::reload_test_settings(); @@ -66,6 +69,7 @@ impl TestEnvGuard { old_cc_switch_config_dir, old_claude_config_dir, old_codex_home, + old_xdg_runtime_dir, } } @@ -82,6 +86,7 @@ impl Drop for TestEnvGuard { restore_env("CC_SWITCH_CONFIG_DIR", &self.old_cc_switch_config_dir); restore_env("CLAUDE_CONFIG_DIR", &self.old_claude_config_dir); restore_env("CODEX_HOME", &self.old_codex_home); + restore_env("XDG_RUNTIME_DIR", &self.old_xdg_runtime_dir); set_test_home_override(self.old_home.as_deref().map(Path::new)); crate::settings::reload_test_settings(); } diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index d1695b6f..48767e21 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -546,6 +546,10 @@ mod tests { } #[tokio::test] + #[expect( + clippy::await_holding_lock, + reason = "test serializes global proxy client state" + )] async fn send_http_request_http_error_preview_handles_multibyte_truncation() { let _guard = proxy_test_lock(); crate::proxy::http_client::apply_proxy(None).expect("reset proxy to direct"); @@ -583,6 +587,10 @@ mod tests { } #[tokio::test] + #[expect( + clippy::await_holding_lock, + reason = "test serializes global proxy client state" + )] async fn send_http_request_uses_shared_proxy_aware_client() { let _guard = proxy_test_lock(); @@ -619,6 +627,10 @@ mod tests { } #[tokio::test] + #[expect( + clippy::await_holding_lock, + reason = "test serializes global proxy client state" + )] async fn execute_usage_script_custom_template_skips_same_origin_check() { let _guard = proxy_test_lock(); crate::proxy::http_client::apply_proxy(None).expect("reset proxy to direct"); diff --git a/src-tauri/tests/app_config_load.rs b/src-tauri/tests/app_config_load.rs index 1099b222..5e6e64f4 100644 --- a/src-tauri/tests/app_config_load.rs +++ b/src-tauri/tests/app_config_load.rs @@ -166,8 +166,10 @@ fn update_settings_persists_openclaw_override_dir() { let home = ensure_test_home(); let _config_dir = ConfigDirEnvGuard::set(None); - let mut settings = AppSettings::default(); - settings.openclaw_config_dir = Some("~/custom-openclaw".to_string()); + let settings = AppSettings { + openclaw_config_dir: Some("~/custom-openclaw".to_string()), + ..Default::default() + }; update_settings(settings).expect("save settings with openclaw override"); let path = home.join(".cc-switch").join("settings.json"); @@ -188,8 +190,10 @@ fn update_settings_persists_hermes_override_dir() { let home = ensure_test_home(); let _config_dir = ConfigDirEnvGuard::set(None); - let mut settings = AppSettings::default(); - settings.hermes_config_dir = Some("~/custom-hermes".to_string()); + let settings = AppSettings { + hermes_config_dir: Some("~/custom-hermes".to_string()), + ..Default::default() + }; update_settings(settings).expect("save settings with hermes override"); let path = home.join(".cc-switch").join("settings.json"); @@ -211,8 +215,10 @@ fn update_settings_uses_cc_switch_config_dir_override_for_settings_path() { let override_dir = home.join("custom-config-root"); let _config_dir = ConfigDirEnvGuard::set(Some(override_dir.to_string_lossy().as_ref())); - let mut settings = AppSettings::default(); - settings.openclaw_config_dir = Some("~/custom-openclaw".to_string()); + let settings = AppSettings { + openclaw_config_dir: Some("~/custom-openclaw".to_string()), + ..Default::default() + }; update_settings(settings).expect("save settings with config dir override"); let override_settings = override_dir.join("settings.json"); diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 8c1e8d14..a75dd9da 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -691,8 +691,10 @@ fn set_apps_replaces_matrix_and_syncs_opencode_live_config() { let state = state_from_config(config); - let mut apps = McpApps::default(); - apps.opencode = true; + let apps = McpApps { + opencode: true, + ..Default::default() + }; assert!( McpService::set_apps(&state, "matrix-server", apps).expect("set apps succeeds"), "existing server should be updated" diff --git a/src-tauri/tests/openclaw_config.rs b/src-tauri/tests/openclaw_config.rs index 2e28b18b..9d9d5ca5 100644 --- a/src-tauri/tests/openclaw_config.rs +++ b/src-tauri/tests/openclaw_config.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use serde_json::json; use serial_test::serial; use std::collections::HashMap; @@ -30,6 +32,8 @@ mod error { #[source] source: serde_json::Error, }, + #[error("{0}")] + Message(String), #[error("锁获取失败: {0}")] Lock(String), #[error("{zh} ({en})")] @@ -209,6 +213,42 @@ mod settings { } } +mod app_config { + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] + pub enum AppType { + Claude, + Codex, + Gemini, + OpenCode, + Hermes, + OpenClaw, + } + + impl AppType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Gemini => "gemini", + Self::OpenCode => "opencode", + Self::Hermes => "hermes", + Self::OpenClaw => "openclaw", + } + } + } +} + +#[path = "../src/services/provider/live_merge.rs"] +pub mod live_merge; + +mod services { + pub mod provider { + pub use crate::live_merge; + } +} + mod test_support { use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, OnceLock, RwLock}; diff --git a/src-tauri/tests/opencode_provider.rs b/src-tauri/tests/opencode_provider.rs index dc28ddad..9753bc8d 100644 --- a/src-tauri/tests/opencode_provider.rs +++ b/src-tauri/tests/opencode_provider.rs @@ -92,8 +92,22 @@ fn opencode_add_syncs_all_providers_to_live_config() { assert!(providers.contains_key("anthropic")); } +fn assert_live_conflict(err: AppError, paths: &[&str]) { + let message = err.to_string(); + assert!( + message.contains("Live configuration has conflicting local changes"), + "expected live conflict summary, got: {message}" + ); + for path in paths { + assert!( + message.contains(path), + "expected conflict path {path}, got: {message}" + ); + } +} + #[test] -fn opencode_update_live_backed_provider_rewrites_live_config() { +fn opencode_update_live_backed_provider_conflicts_on_changed_live_field() { let _guard = lock_test_mutex(); reset_test_fs(); let home = ensure_test_home(); @@ -133,7 +147,7 @@ fn opencode_update_live_backed_provider_rewrites_live_config() { .expect("seed opencode live config"); let state = state_from_config(config); - ProviderService::update( + let err = ProviderService::update( &state, AppType::OpenCode, opencode_provider( @@ -142,12 +156,13 @@ fn opencode_update_live_backed_provider_rewrites_live_config() { "https://new.example.com/v1", ), ) - .expect("updating live-backed opencode provider should rewrite live config"); + .expect_err("changed live opencode field should conflict by default"); + assert_live_conflict(err, &["options.baseURL"]); let live = read_opencode_live(&opencode_path); assert_eq!( live["provider"]["live-provider"]["options"]["baseURL"], - json!("https://new.example.com/v1") + json!("https://old.example.com/v1") ); } @@ -294,7 +309,7 @@ fn opencode_remove_from_live_config_marks_db_only_until_readded() { ); } - ProviderService::update( + let err = ProviderService::update( &state, AppType::OpenCode, opencode_provider( @@ -303,10 +318,11 @@ fn opencode_remove_from_live_config_marks_db_only_until_readded() { "https://toggle.new.example.com/v1", ), ) - .expect("re-added opencode provider edit should update live config"); + .expect_err("re-added opencode provider edit should conflict on changed live field"); + assert_live_conflict(err, &["options.baseURL"]); assert_eq!( read_opencode_live(&opencode_path)["provider"]["toggle"]["options"]["baseURL"], - json!("https://toggle.new.example.com/v1") + json!("https://toggle.old.example.com/v1") ); } diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 72c5ee32..14287287 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -1,3 +1,5 @@ +#![allow(clippy::await_holding_lock)] + use serde_json::json; use serial_test::serial; use std::collections::HashMap; @@ -1626,10 +1628,12 @@ fn switch_provider_updates_claude_live_and_state() { } let legacy_live = json!({ "env": { - "ANTHROPIC_API_KEY": "legacy-key" + "ANTHROPIC_API_KEY": "fresh-key", + "LOCAL_ONLY": "preserve-me" }, "workspace": { - "path": "/tmp/workspace" + "path": "/tmp/new-workspace", + "localOnly": true } }); std::fs::write( @@ -1784,7 +1788,7 @@ fn switch_provider_codex_rejects_missing_auth() { #[tokio::test] #[serial] -async fn switch_provider_under_takeover_keeps_claude_live_pointing_to_proxy_and_updates_restore_backup( +async fn switch_provider_under_takeover_keeps_claude_live_pointing_to_proxy_and_preserves_restore_backup( ) { let _guard = lock_test_mutex(); reset_test_fs(); @@ -1797,10 +1801,12 @@ async fn switch_provider_under_takeover_keeps_claude_live_pointing_to_proxy_and_ let legacy_live = json!({ "env": { - "ANTHROPIC_API_KEY": "legacy-key" + "ANTHROPIC_API_KEY": "fresh-key", + "LOCAL_ONLY": "preserve-me" }, "workspace": { - "path": "/tmp/workspace" + "path": "/tmp/new-workspace", + "localOnly": true } }); std::fs::write( @@ -1900,11 +1906,36 @@ async fn switch_provider_under_takeover_keeps_claude_live_pointing_to_proxy_and_ .expect("parse claude live backup after takeover-time switch"); assert_eq!( backup_after_switch + .get("env") + .and_then(|env| env.get("LOCAL_ONLY")) + .and_then(|value| value.as_str()), + Some("preserve-me"), + "takeover-time switch should preserve the original live backup used for restore" + ); + assert_eq!( + backup_after_switch + .get("env") + .and_then(|env| env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC")) + .and_then(|value| value.as_str()), + None, + "provider common config belongs in generated failover snapshots, not the restore backup" + ); + + let failover_snapshot = state + .db + .get_failover_live_snapshot("claude", "new-provider") + .await + .expect("read generated failover snapshot after takeover-time switch") + .expect("new provider failover snapshot should exist after switch"); + let failover_snapshot: serde_json::Value = serde_json::from_str(&failover_snapshot.config_json) + .expect("parse generated failover snapshot after takeover-time switch"); + assert_eq!( + failover_snapshot .get("env") .and_then(|env| env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC")) .and_then(|value| value.as_str()), Some("1"), - "takeover-time switch should refresh the stored backup with the same Claude common snippet semantics" + "takeover-time switch should generate a provider snapshot with normal Claude common snippet semantics" ); state @@ -1921,14 +1952,22 @@ async fn switch_provider_under_takeover_keeps_claude_live_pointing_to_proxy_and_ .and_then(|env| env.get("ANTHROPIC_API_KEY")) .and_then(|value| value.as_str()), Some("fresh-key"), - "restore after a takeover-time switch should recover the new provider config, not the pre-switch one" + "restore after a takeover-time switch should recover the original local live config" + ); + assert_eq!( + restored_live + .get("env") + .and_then(|env| env.get("LOCAL_ONLY")) + .and_then(|value| value.as_str()), + Some("preserve-me"), + "restore after a takeover-time switch should keep local-only live config values" ); assert_eq!( restored_live .get("env") .and_then(|env| env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC")) .and_then(|value| value.as_str()), - Some("1"), - "restore after a takeover-time switch should keep the normal Claude common snippet semantics" + None, + "restore after takeover should not write provider common config into the preserved local backup" ); } diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 543500b9..332f7da3 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -30,6 +30,20 @@ fn read_openclaw_live_config_json5(path: &std::path::Path) -> serde_json::Value json5::from_str(&source).expect("parse openclaw live config as json5") } +fn assert_live_conflict(err: AppError, paths: &[&str]) { + let message = err.to_string(); + assert!( + message.contains("Live configuration has conflicting local changes"), + "expected live conflict summary, got: {message}" + ); + for path in paths { + assert!( + message.contains(path), + "expected conflict path {path}, got: {message}" + ); + } +} + fn openclaw_db_providers(state: &AppState) -> IndexMap { state .db @@ -251,7 +265,7 @@ command = "echo" } #[test] -fn provider_service_switch_codex_default_overwrites_official_auth_when_preservation_off() { +fn provider_service_switch_codex_conflicting_live_auth_fails_when_preservation_off() { let _guard = lock_test_mutex(); reset_test_fs(); let _home = ensure_test_home(); @@ -273,7 +287,16 @@ base_url = "https://rightcode.example/v1" wire_api = "responses" requires_openai_auth = true "#; - write_codex_live_atomic(&live_auth, Some(legacy_config)) + let third_party_config = r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + write_codex_live_atomic(&live_auth, Some(third_party_config)) .expect("seed existing Codex OAuth live config"); let mut initial_config = MultiAppConfig::default(); @@ -301,15 +324,7 @@ requires_openai_auth = true "AiHubMix".to_string(), json!({ "auth": {"OPENAI_API_KEY": "third-party-key"}, - "config": r#"model_provider = "aihubmix" -model = "gpt-5.4" - -[model_providers.aihubmix] -name = "AiHubMix" -base_url = "https://aihubmix.example/v1" -wire_api = "responses" -requires_openai_auth = true -"# + "config": third_party_config }), None, ), @@ -318,19 +333,34 @@ requires_openai_auth = true let state = state_from_config(initial_config); - ProviderService::switch(&state, AppType::Codex, "third-party") - .expect("switch to third-party provider should succeed"); + let err = ProviderService::switch(&state, AppType::Codex, "third-party") + .expect_err("conflicting Codex auth should fail non-interactively"); + assert_live_conflict(err, &["OPENAI_API_KEY"]); let auth_value: serde_json::Value = read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json"); assert_eq!( - auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()), - Some("third-party-key"), - "default (preservation off) should overwrite auth.json with the third-party API key" + auth_value, live_auth, + "failed switch should leave auth.json untouched" ); - assert!( - auth_value.pointer("/tokens/access_token").is_none(), - "default switch must clear the official ChatGPT OAuth token from live auth.json" + + let config_text = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + assert_eq!( + config_text, third_party_config, + "failed switch should leave config.toml untouched" + ); + + let guard = state + .config + .read() + .expect("read config after failed switch"); + let manager = guard + .get_manager(&AppType::Codex) + .expect("codex manager after failed switch"); + assert_eq!( + manager.current, "legacy-provider", + "failed switch should roll back current provider" ); } @@ -340,7 +370,7 @@ fn provider_service_switch_codex_preserves_provider_model_provider_after_history reset_test_fs(); let _home = ensure_test_home(); - let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" }); + let legacy_auth = json!({}); let legacy_config = r#"model_provider = "rightcode" model = "gpt-5.4" @@ -350,7 +380,11 @@ base_url = "https://rightcode.example/v1" wire_api = "responses" requires_openai_auth = true "#; - write_codex_live_atomic(&legacy_auth, Some(legacy_config)) + let live_config = r#"[mcp_servers.legacy] +type = "stdio" +command = "echo" +"#; + write_codex_live_atomic(&legacy_auth, Some(live_config)) .expect("seed existing codex live config"); let mut initial_config = MultiAppConfig::default(); @@ -440,7 +474,7 @@ fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider reset_test_fs(); let _home = ensure_test_home(); - let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" }); + let legacy_auth = json!({}); let provider_a_config = r#"model_provider = "rightcode" model = "gpt-5.4" @@ -450,7 +484,11 @@ base_url = "https://rightcode.example/v1" wire_api = "responses" requires_openai_auth = true "#; - write_codex_live_atomic(&legacy_auth, Some(provider_a_config)) + let live_config = r#"[mcp_servers.legacy] +type = "stdio" +command = "echo" +"#; + write_codex_live_atomic(&legacy_auth, Some(live_config)) .expect("seed existing codex live config"); let mut initial_config = MultiAppConfig::default(); @@ -522,8 +560,6 @@ requires_openai_auth = true ProviderService::switch(&state, AppType::Codex, "provider-b") .expect("switch to provider b should succeed"); - ProviderService::switch(&state, AppType::Codex, "provider-c") - .expect("switch to provider c should succeed"); let guard = state.config.read().expect("read config after switches"); let provider_b_config = guard @@ -563,11 +599,10 @@ fn provider_service_switch_codex_backfill_ignores_invalid_template_config() { reset_test_fs(); let _home = ensure_test_home(); - let live_auth = json!({ "OPENAI_API_KEY": "live-key" }); - let live_config = r#"model_provider = "stable" - -[model_providers.stable] -base_url = "https://stable.example/v1" + let live_auth = json!({}); + let live_config = r#"[mcp_servers.local] +type = "stdio" +command = "echo" "#; write_codex_live_atomic(&live_auth, Some(live_config)).expect("seed codex live config"); @@ -620,13 +655,13 @@ fn update_current_codex_provider_preserves_managed_mcp_servers() { let _home = ensure_test_home(); let common_snippet = "disable_response_storage = true"; - let live_auth = json!({ "OPENAI_API_KEY": "live-key" }); + let live_auth = json!({ "OPENAI_API_KEY": "updated-key" }); let live_config = r#"disable_response_storage = true model_provider = "current" model = "gpt-5.2-codex" [model_providers.current] -base_url = "https://api.before.example/v1" +base_url = "https://api.after.example/v1" wire_api = "responses" requires_openai_auth = true @@ -695,13 +730,12 @@ fn add_current_codex_provider_preserves_managed_mcp_servers() { let _home = ensure_test_home(); let common_snippet = "disable_response_storage = true"; - let live_auth = json!({ "OPENAI_API_KEY": "live-key" }); - let live_config = r#"disable_response_storage = true -model_provider = "legacy" + let live_auth = json!({ "OPENAI_API_KEY": "fresh-key" }); + let live_config = r#"model_provider = "new" model = "gpt-5.2-codex" -[model_providers.legacy] -base_url = "https://api.legacy.example/v1" +[model_providers.new] +base_url = "https://api.new.example/v1" wire_api = "responses" requires_openai_auth = true @@ -754,13 +788,13 @@ fn update_current_codex_provider_uses_db_current_even_if_config_current_drifted( let _home = ensure_test_home(); let common_snippet = "disable_response_storage = true"; - let live_auth = json!({ "OPENAI_API_KEY": "live-key" }); + let live_auth = json!({ "OPENAI_API_KEY": "updated-key" }); let live_config = r#"disable_response_storage = true -model_provider = "stale" +model_provider = "current" model = "gpt-5.2-codex" -[model_providers.stale] -base_url = "https://api.stale.example/v1" +[model_providers.current] +base_url = "https://api.after.example/v1" wire_api = "responses" requires_openai_auth = true @@ -851,13 +885,13 @@ fn add_first_codex_provider_sets_db_current_and_rewrites_live_when_db_current_mi let _home = ensure_test_home(); let common_snippet = "disable_response_storage = true"; - let live_auth = json!({ "OPENAI_API_KEY": "live-key" }); + let live_auth = json!({ "OPENAI_API_KEY": "fresh-key" }); let live_config = r#"disable_response_storage = true -model_provider = "stale" +model_provider = "new" model = "gpt-5.2-codex" -[model_providers.stale] -base_url = "https://api.stale.example/v1" +[model_providers.new] +base_url = "https://api.new.example/v1" wire_api = "responses" requires_openai_auth = true @@ -1469,7 +1503,7 @@ fn switch_gemini_merges_existing_settings_preserving_mcp_servers() { } #[test] -fn provider_service_switch_claude_updates_live_and_state() { +fn provider_service_switch_claude_merges_live_and_state() { let _guard = lock_test_mutex(); reset_test_fs(); let _home = ensure_test_home(); @@ -1480,7 +1514,7 @@ fn provider_service_switch_claude_updates_live_and_state() { } let legacy_live = json!({ "env": { - "ANTHROPIC_API_KEY": "legacy-key" + "LOCAL_ONLY": "keep-me" }, "workspace": { "path": "/tmp/workspace" @@ -1516,7 +1550,7 @@ fn provider_service_switch_claude_updates_live_and_state() { "Fresh Claude".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "fresh-key" }, - "workspace": { "path": "/tmp/new-workspace" } + "permissions": { "allow": ["Bash(cargo test:*)"] } }), None, ), @@ -1536,7 +1570,29 @@ fn provider_service_switch_claude_updates_live_and_state() { .and_then(|env| env.get("ANTHROPIC_API_KEY")) .and_then(|key| key.as_str()), Some("fresh-key"), - "live settings.json should reflect new provider auth" + "live settings.json should include new provider auth" + ); + assert_eq!( + live_after + .get("env") + .and_then(|env| env.get("LOCAL_ONLY")) + .and_then(|key| key.as_str()), + Some("keep-me"), + "live settings.json should preserve local-only env keys" + ); + assert_eq!( + live_after + .pointer("/workspace/path") + .and_then(|value| value.as_str()), + Some("/tmp/workspace"), + "live settings.json should preserve local-only nested objects" + ); + assert_eq!( + live_after + .pointer("/permissions/allow/0") + .and_then(|value| value.as_str()), + Some("Bash(cargo test:*)"), + "live settings.json should add provider-only nested objects" ); let guard = state @@ -1558,6 +1614,99 @@ fn provider_service_switch_claude_updates_live_and_state() { ); } +#[test] +fn provider_service_switch_claude_conflict_fails_without_touching_live_or_state() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let settings_path = get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).expect("create claude settings dir"); + } + let legacy_live = json!({ + "env": { + "ANTHROPIC_API_KEY": "legacy-key" + }, + "workspace": { + "path": "/tmp/workspace" + } + }); + std::fs::write( + &settings_path, + serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"), + ) + .expect("seed claude live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "Legacy Claude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "stale-key" } + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "Fresh Claude".to_string(), + json!({ + "env": { "ANTHROPIC_API_KEY": "fresh-key" }, + "workspace": { "path": "/tmp/new-workspace" } + }), + None, + ), + ); + } + + let state = state_from_config(config); + + let err = ProviderService::switch(&state, AppType::Claude, "new-provider") + .expect_err("conflicting live settings should fail non-interactively"); + let message = err.to_string(); + assert!( + message.contains("Live configuration has conflicting local changes"), + "expected conflict summary, got: {message}" + ); + assert!( + message.contains("env.ANTHROPIC_API_KEY"), + "expected conflicting env path, got: {message}" + ); + assert!( + message.contains("workspace.path"), + "expected conflicting workspace path, got: {message}" + ); + + let live_after: serde_json::Value = + read_json_file(&settings_path).expect("read claude live settings after failed switch"); + assert_eq!( + live_after, legacy_live, + "failed conflict switch should restore original live settings" + ); + + let guard = state + .config + .read() + .expect("read claude config after failed switch"); + let manager = guard + .get_manager(&AppType::Claude) + .expect("claude manager after failed switch"); + assert_eq!( + manager.current, "old-provider", + "failed conflict switch should roll back current provider" + ); +} + #[test] fn provider_service_switch_missing_provider_returns_error() { let _guard = lock_test_mutex(); @@ -1767,9 +1916,10 @@ fn provider_service_sync_current_to_live_openclaw_skips_saved_only_snapshot_prov "mode": "merge", "providers": { "keep": { - "apiKey": "sk-keep-old", - "baseUrl": "https://keep.old.example/v1", - "models": [{ "id": "keep-model-old" }] + "apiKey": "sk-keep-new", + "baseUrl": "https://keep.new.example/v1", + "models": [{ "id": "keep-model-old" }], + "localOnly": "preserved" } } }, @@ -1905,9 +2055,10 @@ fn provider_service_sync_current_to_live_openclaw_ignores_malformed_live_members "mode": "merge", "providers": { "keep": { - "apiKey": "sk-keep-old", - "baseUrl": "https://keep.old.example/v1", - "models": [{ "id": "keep-model-old" }] + "apiKey": "sk-keep-new", + "baseUrl": "https://keep.new.example/v1", + "models": [{ "id": "keep-model-old" }], + "localOnly": "preserved" }, "model-less": { "apiKey": "sk-model-less-old", @@ -2002,9 +2153,10 @@ fn provider_service_sync_current_to_live_openclaw_ignores_blank_model_ids_in_liv "mode": "merge", "providers": { "keep": { - "apiKey": "sk-keep-old", - "baseUrl": "https://keep.old.example/v1", - "models": [{ "id": "keep-model" }] + "apiKey": "sk-keep-new", + "baseUrl": "https://keep.new.example/v1", + "models": [{ "id": "keep-model" }], + "localOnly": "preserved" }, "blank-model-id": { "apiKey": "sk-blank-old", @@ -2436,11 +2588,9 @@ fn provider_service_update_openclaw_allows_model_catalog_refs_to_dangle() { providers: { keep: { apiKey: 'sk-keep', - baseUrl: 'https://keep.example/v1', - models: [ - { id: 'primary-model' }, - { id: 'fallback-model' }, - ], + baseUrl: 'https://keep.example/v2', + models: [{ id: 'primary-model' }], + localOnly: 'preserved', }, }, }, @@ -2494,6 +2644,10 @@ fn provider_service_update_openclaw_allows_model_catalog_refs_to_dangle() { live_after["models"]["providers"]["keep"]["baseUrl"], json!("https://keep.example/v2") ); + assert_eq!( + live_after["models"]["providers"]["keep"]["localOnly"], + json!("preserved") + ); assert_eq!( live_after["agents"]["defaults"]["models"]["keep/fallback-model"]["alias"], json!("Fallback") @@ -2659,9 +2813,10 @@ fn provider_service_update_in_config_openclaw_updates_live_config() { mode: 'merge', providers: { keep: { - apiKey: 'sk-keep-old', - baseUrl: 'https://keep.old.example/v1', - models: [{ id: 'keep-model' }], + apiKey: 'sk-keep-new', + baseUrl: 'https://keep.new.example/v1', + models: [{ id: 'keep-model-updated' }], + localOnly: 'preserved', }, }, }, @@ -2693,8 +2848,8 @@ fn provider_service_update_in_config_openclaw_updates_live_config() { json!("https://keep.new.example/v1") ); assert_eq!( - live_after["models"]["providers"]["keep"]["models"][0]["id"], - json!("keep-model-updated") + live_after["models"]["providers"]["keep"]["localOnly"], + json!("preserved") ); } @@ -3022,11 +3177,9 @@ fn provider_service_update_openclaw_allows_default_model_refs_to_dangle() { providers: { keep: { apiKey: 'sk-keep', - baseUrl: 'https://keep.example/v1', - models: [ - { id: 'primary-model' }, - { id: 'fallback-model' }, - ], + baseUrl: 'https://keep.example/v2', + models: [{ id: 'primary-model' }], + localOnly: 'preserved', }, }, }, @@ -3087,6 +3240,10 @@ fn provider_service_update_openclaw_allows_default_model_refs_to_dangle() { live_after["models"]["providers"]["keep"]["models"], json!([{ "id": "primary-model" }]) ); + assert_eq!( + live_after["models"]["providers"]["keep"]["localOnly"], + json!("preserved") + ); assert_eq!( live_after["agents"]["defaults"]["model"]["primary"], json!("keep/fallback-model"), @@ -3794,7 +3951,7 @@ fn provider_service_switch_codex_missing_auth_is_rejected() { } #[test] -fn provider_service_switch_codex_openai_official_writes_auth_json_from_provider_snapshot() { +fn provider_service_switch_codex_openai_official_merges_auth_json_from_provider_snapshot() { let _guard = lock_test_mutex(); reset_test_fs(); let home = ensure_test_home(); @@ -3802,7 +3959,7 @@ fn provider_service_switch_codex_openai_official_writes_auth_json_from_provider_ std::fs::create_dir_all(home.join(".codex")).expect("create codex dir (initialized)"); let auth_path = cc_switch_lib::get_codex_auth_path(); - std::fs::write(&auth_path, r#"{"OPENAI_API_KEY":"stale-key"}"#).expect("seed auth.json"); + std::fs::write(&auth_path, r#"{"LOCAL_ONLY":"preserve-me"}"#).expect("seed auth.json"); assert!(auth_path.exists(), "auth.json should exist before switch"); let mut config = MultiAppConfig::default(); @@ -3848,7 +4005,12 @@ fn provider_service_switch_codex_openai_official_writes_auth_json_from_provider_ assert_eq!( auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()), Some("sk-official"), - "auth.json should be overwritten from the provider snapshot" + "auth.json should add the provider snapshot auth key" + ); + assert_eq!( + auth_value.get("LOCAL_ONLY").and_then(|v| v.as_str()), + Some("preserve-me"), + "auth.json should preserve local-only auth fields" ); } @@ -3943,7 +4105,7 @@ fn provider_service_switch_codex_openai_official_preserves_oauth_auth_and_common std::fs::create_dir_all(home.join(".codex")).expect("create codex dir (initialized)"); let auth_path = cc_switch_lib::get_codex_auth_path(); - std::fs::write(&auth_path, r#"{"OPENAI_API_KEY":"stale-key"}"#).expect("seed auth.json"); + std::fs::write(&auth_path, r#"{"LOCAL_ONLY":"preserve-me"}"#).expect("seed auth.json"); let mut config = MultiAppConfig::default(); { @@ -3996,6 +4158,11 @@ fn provider_service_switch_codex_openai_official_preserves_oauth_auth_and_common json!("oauth-token"), "official provider should restore the stored OAuth auth snapshot" ); + assert_eq!( + auth_value.get("LOCAL_ONLY").and_then(|v| v.as_str()), + Some("preserve-me"), + "official provider should preserve local-only auth fields" + ); let live_text = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); @@ -4586,8 +4753,10 @@ fn provider_service_delete_provider_selected_in_local_settings_returns_error() { ); } - let mut settings = AppSettings::default(); - settings.current_provider_claude = Some("delete".to_string()); + let settings = AppSettings { + current_provider_claude: Some("delete".to_string()), + ..Default::default() + }; update_settings(settings).expect("set local current provider override"); let app_state = state_from_config(config); diff --git a/src-tauri/tests/provider_switch_settings_sync.rs b/src-tauri/tests/provider_switch_settings_sync.rs index 04bb3994..5e9c4376 100644 --- a/src-tauri/tests/provider_switch_settings_sync.rs +++ b/src-tauri/tests/provider_switch_settings_sync.rs @@ -28,8 +28,10 @@ fn switch_non_additive_updates_local_settings_current_provider() { reset_test_fs(); let _home = ensure_test_home(); - let mut settings = AppSettings::default(); - settings.current_provider_codex = Some("old-provider".to_string()); + let settings = AppSettings { + current_provider_codex: Some("old-provider".to_string()), + ..Default::default() + }; update_settings(settings).expect("seed local settings current provider"); let mut config = MultiAppConfig::default(); diff --git a/src-tauri/tests/proxy_claude_forwarder_alignment.rs b/src-tauri/tests/proxy_claude_forwarder_alignment.rs index 7f049332..9c3261b6 100644 --- a/src-tauri/tests/proxy_claude_forwarder_alignment.rs +++ b/src-tauri/tests/proxy_claude_forwarder_alignment.rs @@ -1,3 +1,5 @@ +#![allow(clippy::await_holding_lock)] + use std::{ collections::VecDeque, env, @@ -92,7 +94,6 @@ impl ProxyEnvGuard { let saved = proxy_keys .into_iter() .chain(bypass_keys) - .into_iter() .map(|key| { let old = env::var(key).ok(); if bypass_keys.contains(&key) { @@ -386,7 +387,7 @@ async fn start_proxy_service( db.set_current_provider("claude", &provider.id) .expect("set current provider"); - let mut app_proxy = db + let app_proxy = db .get_proxy_config_for_app("claude") .await .expect("read claude app proxy config"); @@ -441,7 +442,7 @@ async fn start_proxy_service_with_rectifier_config( ) .expect("store rectifier config"); - let mut app_proxy = db + let app_proxy = db .get_proxy_config_for_app("claude") .await .expect("read claude app proxy config"); diff --git a/src-tauri/tests/proxy_database.rs b/src-tauri/tests/proxy_database.rs index 26f12390..e6f37a3f 100644 --- a/src-tauri/tests/proxy_database.rs +++ b/src-tauri/tests/proxy_database.rs @@ -72,6 +72,75 @@ async fn pricing_model_source_round_trips_and_rejects_unknown_values() -> Result Ok(()) } +#[tokio::test] +async fn failover_live_snapshots_round_trip_and_delete() -> Result<(), AppError> { + let db = Database::memory()?; + save_queue_provider(&db, "claude", "provider-a")?; + save_queue_provider(&db, "claude", "provider-b")?; + + db.save_failover_live_snapshot("claude", "provider-a", r#"{"env":{"TOKEN":"a"}}"#) + .await?; + db.save_failover_live_snapshot("claude", "provider-b", r#"{"env":{"TOKEN":"b"}}"#) + .await?; + + let snapshot = db + .get_failover_live_snapshot("claude", "provider-a") + .await? + .expect("provider-a snapshot"); + assert_eq!(snapshot.app_type, "claude"); + assert_eq!(snapshot.provider_id, "provider-a"); + assert_eq!(snapshot.config_json, r#"{"env":{"TOKEN":"a"}}"#); + assert!(!snapshot.generated_at.is_empty()); + + let listed = db.list_failover_live_snapshots("claude").await?; + assert_eq!(listed.len(), 2); + + db.delete_failover_live_snapshot("claude", "provider-a") + .await?; + assert!(db + .get_failover_live_snapshot("claude", "provider-a") + .await? + .is_none()); + assert_eq!(db.list_failover_live_snapshots("claude").await?.len(), 1); + + db.delete_failover_live_snapshots_for_app("claude").await?; + assert!(db.list_failover_live_snapshots("claude").await?.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn delete_all_failover_live_snapshots_clears_every_app() -> Result<(), AppError> { + let db = Database::memory()?; + save_queue_provider(&db, "claude", "claude-provider")?; + save_queue_provider(&db, "codex", "codex-provider")?; + db.save_failover_live_snapshot("claude", "claude-provider", "{}") + .await?; + db.save_failover_live_snapshot("codex", "codex-provider", "{}") + .await?; + + db.delete_all_failover_live_snapshots().await?; + + assert!(db.list_failover_live_snapshots("claude").await?.is_empty()); + assert!(db.list_failover_live_snapshots("codex").await?.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn failover_live_snapshots_are_deleted_with_provider() -> Result<(), AppError> { + let db = Database::memory()?; + save_queue_provider(&db, "claude", "provider-a")?; + db.save_failover_live_snapshot("claude", "provider-a", "{}") + .await?; + + db.delete_provider("claude", "provider-a")?; + + assert!(db + .get_failover_live_snapshot("claude", "provider-a") + .await? + .is_none()); + Ok(()) +} + #[tokio::test] async fn clear_auto_failover_for_supported_apps_disables_failover_flags() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/tests/proxy_service.rs b/src-tauri/tests/proxy_service.rs index 59902537..7e47d815 100644 --- a/src-tauri/tests/proxy_service.rs +++ b/src-tauri/tests/proxy_service.rs @@ -1,3 +1,5 @@ +#![allow(clippy::await_holding_lock)] + use std::{ net::TcpListener, sync::Arc, diff --git a/src-tauri/tests/proxy_takeover.rs b/src-tauri/tests/proxy_takeover.rs index f880b409..78a58c75 100644 --- a/src-tauri/tests/proxy_takeover.rs +++ b/src-tauri/tests/proxy_takeover.rs @@ -1,3 +1,5 @@ +#![allow(clippy::await_holding_lock)] + use std::{net::TcpListener, sync::Arc}; use cc_switch_lib::{ diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index 9050bef6..79279c28 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use serial_test::serial; use std::ffi::OsString; use tempfile::TempDir; diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index 47faa3c8..e5050ed7 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use serde_json::json; use serial_test::serial; use std::ffi::OsString; @@ -512,14 +514,16 @@ fn set_visible_apps_rejects_zero_selection() { fn update_settings_rejects_all_false_visible_apps() { let _home = HomeGuard::new(); - let mut settings = AppSettings::default(); - settings.visible_apps = VisibleApps { - claude: false, - codex: false, - gemini: false, - opencode: false, - openclaw: false, - hermes: false, + let settings = AppSettings { + visible_apps: VisibleApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + hermes: false, + }, + ..Default::default() }; let err = diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index c4e788ba..2e5ed112 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock}; use std::time::{Duration, Instant};