#![allow(non_snake_case)] use tauri::AppHandle; use tauri_plugin_updater::UpdaterExt; fn merge_settings_for_save( mut incoming: crate::settings::AppSettings, existing: &crate::settings::AppSettings, ) -> crate::settings::AppSettings { match (&mut incoming.webdav_sync, &existing.webdav_sync) { // incoming 没有 webdav → 保留现有 (None, _) => { incoming.webdav_sync = existing.webdav_sync.clone(); } // incoming 有 webdav 但密码为空,且现有有密码 → 填回现有密码 // (get_settings_for_frontend 总是清空密码,所以通过 save_settings // 传入的空密码意味着"保持现有"而非"用户主动清空") (Some(incoming_sync), Some(existing_sync)) if incoming_sync.password.is_empty() && !existing_sync.password.is_empty() => { incoming_sync.password = existing_sync.password.clone(); } _ => {} } match (&mut incoming.s3_sync, &existing.s3_sync) { // incoming 没有 s3 → 保留现有 (None, _) => { incoming.s3_sync = existing.s3_sync.clone(); } // incoming 有 s3 但密钥为空,且现有有密钥 → 填回现有密钥 (Some(incoming_sync), Some(existing_sync)) if incoming_sync.secret_access_key.is_empty() && !existing_sync.secret_access_key.is_empty() => { incoming_sync.secret_access_key = existing_sync.secret_access_key.clone(); } _ => {} } if incoming.local_migrations.is_none() { incoming.local_migrations = existing.local_migrations.clone(); } else if let (Some(incoming_migrations), Some(existing_migrations)) = (&mut incoming.local_migrations, &existing.local_migrations) { if incoming_migrations .codex_third_party_history_provider_bucket_v1 .is_none() { incoming_migrations.codex_third_party_history_provider_bucket_v1 = existing_migrations .codex_third_party_history_provider_bucket_v1 .clone(); } if incoming_migrations.codex_provider_template_v1.is_none() { incoming_migrations.codex_provider_template_v1 = existing_migrations.codex_provider_template_v1.clone(); } } incoming } /// 获取设置 #[tauri::command] pub async fn get_settings() -> Result { Ok(crate::settings::get_settings_for_frontend()) } /// 保存设置 #[tauri::command] pub async fn save_settings(settings: crate::settings::AppSettings) -> Result { let existing = crate::settings::get_settings(); let merged = merge_settings_for_save(settings, &existing); crate::settings::update_settings(merged).map_err(|e| e.to_string())?; Ok(true) } /// 重启应用程序(当 app_config_dir 变更后使用) #[tauri::command] pub async fn restart_app(app: AppHandle) -> Result { crate::save_window_state_before_exit(&app); // 在后台延迟重启,让函数有时间返回响应 tauri::async_runtime::spawn(async move { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; // app.restart() 走 RESTART_EXIT_CODE 路径,ExitRequested 处理器会直接 // 放行给 Tauri 默认 re-exec,不执行代理/Live 清理。但本命令用于 // app_config_dir 变更后的重启:新实例会切到新数据库,拿不到旧库里的 // Live 备份,无法恢复被接管的 Live 配置。因此必须趁旧实例的事件循环 // 仍存活,在这里同步完成恢复(保留代理状态,新实例启动时自动重新接管)。 crate::cleanup_before_exit(&app).await; app.restart(); }); Ok(true) } /// 下载并安装应用更新,然后由后端直接重启应用。 /// /// macOS 更新会原地替换 `.app` bundle。如果先返回前端、再让旧 WebView 调 /// `process.relaunch()`,旧进程可能已经处在 bundle 被替换后的不稳定窗口期。 /// 这里把退出清理、安装和重启串在同一个后端流程中,避免依赖旧前端继续执行。 #[tauri::command] pub async fn install_update_and_restart(app: AppHandle) -> Result { let updater = app .updater_builder() .build() .map_err(|e| format!("初始化更新器失败: {e}"))?; let Some(update) = updater .check() .await .map_err(|e| format!("检查更新失败: {e}"))? else { return Ok(false); }; log::info!("开始下载应用更新: {}", update.version); let bytes = update .download(|_, _| {}, || {}) .await .map_err(|e| format!("下载更新失败: {e}"))?; log::info!("开始安装应用更新: {}", update.version); #[cfg(target_os = "windows")] { // Windows updater 会在 install() 内启动安装器并直接退出当前进程 // (插件内部 std::process::exit(0),绕过 TrayIcon::drop、不发 // NIM_DELETE,会残留死图标——与托盘"退出"路径相同的问题)。 // 因此清理只能放在 install 前执行,且必须显式移除托盘图标。 crate::save_window_state_before_exit(&app); crate::cleanup_before_exit(&app).await; crate::remove_tray_icon_before_exit(&app); crate::destroy_single_instance_lock(&app); tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; update.install(bytes).map_err(|e| { format!( "Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。" ) })?; return Ok(true); } #[cfg(not(target_os = "windows"))] { // macOS/Linux install() 会返回;先安装,避免安装失败时误停代理/撤回接管。 update .install(bytes) .map_err(|e| format!("安装更新失败: {e}"))?; crate::save_window_state_before_exit(&app); crate::cleanup_before_exit(&app).await; log::info!("应用更新安装完成,正在重启应用"); tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; crate::restart_process(&app); } } /// 获取 app_config_dir 覆盖配置 (从 Store) #[tauri::command] pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { Ok(crate::app_store::refresh_app_config_dir_override(&app) .map(|p| p.to_string_lossy().to_string())) } /// 设置 app_config_dir 覆盖配置 (到 Store) #[tauri::command] pub async fn set_app_config_dir_override( app: AppHandle, path: Option, ) -> Result { crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?; Ok(true) } /// 设置开机自启 #[tauri::command] pub async fn set_auto_launch(enabled: bool) -> Result { if enabled { crate::auto_launch::enable_auto_launch().map_err(|e| format!("启用开机自启失败: {e}"))?; } else { crate::auto_launch::disable_auto_launch().map_err(|e| format!("禁用开机自启失败: {e}"))?; } Ok(true) } #[cfg(test)] mod tests { use super::merge_settings_for_save; use crate::settings::{ AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations, S3SyncSettings, WebDavSyncSettings, }; #[test] fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() { let existing = AppSettings { webdav_sync: Some(WebDavSyncSettings { base_url: "https://dav.example.com".to_string(), username: "alice".to_string(), password: "secret".to_string(), ..WebDavSyncSettings::default() }), ..AppSettings::default() }; let incoming = AppSettings::default(); let merged = merge_settings_for_save(incoming, &existing); assert!(merged.webdav_sync.is_some()); assert_eq!( merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()), Some("https://dav.example.com") ); } #[test] fn save_settings_should_keep_incoming_webdav_when_present() { let existing = AppSettings { webdav_sync: Some(WebDavSyncSettings { base_url: "https://dav.old.example.com".to_string(), username: "old".to_string(), password: "old-pass".to_string(), ..WebDavSyncSettings::default() }), ..AppSettings::default() }; let incoming = AppSettings { webdav_sync: Some(WebDavSyncSettings { base_url: "https://dav.new.example.com".to_string(), username: "new".to_string(), password: "new-pass".to_string(), ..WebDavSyncSettings::default() }), ..AppSettings::default() }; let merged = merge_settings_for_save(incoming, &existing); assert_eq!( merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()), Some("https://dav.new.example.com") ); } /// Regression test: frontend always receives empty password from /// get_settings_for_frontend(). If a component accidentally spreads /// the full settings object into save_settings, the empty password /// must NOT overwrite the existing one. #[test] fn save_settings_should_preserve_password_when_incoming_has_empty_password() { let existing = AppSettings { webdav_sync: Some(WebDavSyncSettings { base_url: "https://dav.example.com".to_string(), username: "alice".to_string(), password: "secret".to_string(), ..WebDavSyncSettings::default() }), ..AppSettings::default() }; // Simulate frontend sending settings with cleared password let incoming = AppSettings { webdav_sync: Some(WebDavSyncSettings { base_url: "https://dav.example.com".to_string(), username: "alice".to_string(), password: "".to_string(), ..WebDavSyncSettings::default() }), ..AppSettings::default() }; let merged = merge_settings_for_save(incoming, &existing); assert_eq!( merged.webdav_sync.as_ref().map(|v| v.password.as_str()), Some("secret"), "empty password from frontend must not overwrite existing password" ); } /// When both incoming and existing have no password, merge should /// work without panicking and keep the empty state. #[test] fn save_settings_should_handle_both_empty_passwords() { let existing = AppSettings { webdav_sync: Some(WebDavSyncSettings { base_url: "https://dav.example.com".to_string(), username: "alice".to_string(), password: "".to_string(), ..WebDavSyncSettings::default() }), ..AppSettings::default() }; let incoming = AppSettings { webdav_sync: Some(WebDavSyncSettings { base_url: "https://dav.example.com".to_string(), username: "alice".to_string(), password: "".to_string(), ..WebDavSyncSettings::default() }), ..AppSettings::default() }; let merged = merge_settings_for_save(incoming, &existing); assert_eq!( merged.webdav_sync.as_ref().map(|v| v.password.as_str()), Some("") ); } #[test] fn save_settings_should_preserve_existing_s3_when_payload_omits_it() { let existing = AppSettings { s3_sync: Some(S3SyncSettings { bucket: "bucket".to_string(), access_key_id: "ak".to_string(), secret_access_key: "secret".to_string(), ..S3SyncSettings::default() }), ..AppSettings::default() }; let incoming = AppSettings::default(); let merged = merge_settings_for_save(incoming, &existing); assert!(merged.s3_sync.is_some()); assert_eq!( merged .s3_sync .as_ref() .map(|v| v.secret_access_key.as_str()), Some("secret") ); } #[test] fn save_settings_should_preserve_s3_secret_when_incoming_has_empty_secret() { let existing = AppSettings { s3_sync: Some(S3SyncSettings { bucket: "bucket".to_string(), access_key_id: "ak".to_string(), secret_access_key: "secret".to_string(), ..S3SyncSettings::default() }), ..AppSettings::default() }; let incoming = AppSettings { s3_sync: Some(S3SyncSettings { bucket: "bucket".to_string(), access_key_id: "ak".to_string(), secret_access_key: "".to_string(), ..S3SyncSettings::default() }), ..AppSettings::default() }; let merged = merge_settings_for_save(incoming, &existing); assert_eq!( merged .s3_sync .as_ref() .map(|v| v.secret_access_key.as_str()), Some("secret") ); } #[test] fn save_settings_should_preserve_local_migrations_when_payload_omits_it() { let existing = AppSettings { local_migrations: Some(LocalMigrations { codex_third_party_history_provider_bucket_v1: Some( CodexThirdPartyHistoryProviderBucketMigration { completed_at: "2026-05-20T00:00:00Z".to_string(), target_provider_id: "custom".to_string(), source_provider_ids: vec!["rightcode".to_string()], migrated_jsonl_files: 2, migrated_state_rows: 3, scanned_history_files: true, }, ), codex_provider_template_v1: Some(CodexProviderTemplateMigration { completed_at: "2026-05-20T00:01:00Z".to_string(), migrated_provider_ids: vec!["legacy".to_string()], }), }), ..AppSettings::default() }; let incoming = AppSettings::default(); let merged = merge_settings_for_save(incoming, &existing); let migration = merged .local_migrations .as_ref() .and_then(|migrations| { migrations .codex_third_party_history_provider_bucket_v1 .as_ref() }) .expect("local migration marker should be preserved"); assert_eq!(migration.target_provider_id, "custom"); assert_eq!(migration.migrated_jsonl_files, 2); assert_eq!(migration.migrated_state_rows, 3); let template_migration = merged .local_migrations .as_ref() .and_then(|migrations| migrations.codex_provider_template_v1.as_ref()) .expect("template migration marker should be preserved"); assert_eq!( template_migration.migrated_provider_ids, vec!["legacy".to_string()] ); } } /// 获取开机自启状态 #[tauri::command] pub async fn get_auto_launch_status() -> Result { crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}")) } /// 获取整流器配置 #[tauri::command] pub async fn get_rectifier_config( state: tauri::State<'_, crate::AppState>, ) -> Result { state.db.get_rectifier_config().map_err(|e| e.to_string()) } /// 设置整流器配置 #[tauri::command] pub async fn set_rectifier_config( state: tauri::State<'_, crate::AppState>, config: crate::proxy::types::RectifierConfig, ) -> Result { state .db .set_rectifier_config(&config) .map_err(|e| e.to_string())?; Ok(true) } /// 获取优化器配置 #[tauri::command] pub async fn get_optimizer_config( state: tauri::State<'_, crate::AppState>, ) -> Result { state.db.get_optimizer_config().map_err(|e| e.to_string()) } /// 设置优化器配置 #[tauri::command] pub async fn set_optimizer_config( state: tauri::State<'_, crate::AppState>, config: crate::proxy::types::OptimizerConfig, ) -> Result { // Validate cache_ttl: only allow known values match config.cache_ttl.as_str() { "5m" | "1h" => {} other => { return Err(format!( "Invalid cache_ttl value: '{other}'. Allowed values: '5m', '1h'" )) } } state .db .set_optimizer_config(&config) .map_err(|e| e.to_string())?; Ok(true) } /// 获取 Copilot 优化器配置 #[tauri::command] pub async fn get_copilot_optimizer_config( state: tauri::State<'_, crate::AppState>, ) -> Result { state .db .get_copilot_optimizer_config() .map_err(|e| e.to_string()) } /// 设置 Copilot 优化器配置 #[tauri::command] pub async fn set_copilot_optimizer_config( state: tauri::State<'_, crate::AppState>, config: crate::proxy::types::CopilotOptimizerConfig, ) -> Result { state .db .set_copilot_optimizer_config(&config) .map_err(|e| e.to_string())?; Ok(true) } /// 获取日志配置 #[tauri::command] pub async fn get_log_config( state: tauri::State<'_, crate::AppState>, ) -> Result { state.db.get_log_config().map_err(|e| e.to_string()) } /// 设置日志配置 #[tauri::command] pub async fn set_log_config( state: tauri::State<'_, crate::AppState>, config: crate::proxy::types::LogConfig, ) -> Result { state .db .set_log_config(&config) .map_err(|e| e.to_string())?; log::set_max_level(config.to_level_filter()); log::info!( "日志配置已更新: enabled={}, level={}", config.enabled, config.level ); Ok(true) }