Files
CC-Switch/src-tauri/src/commands/settings.rs
T
Jason Young 7bb59fa5a6 fix(proxy): harden takeover-residue recovery across config-dir switches (#4076)
Changing app_config_dir relocates the SQLite database, so a restart
triggered while proxy takeover is active used to strand the live
configs: the new instance reads a fresh DB with no live backups, the
first-run import then persisted the PROXY_MANAGED placeholder as the
`default` provider, and the no-backup recovery path wrote that
placeholder right back to the live files — leaving Claude/Codex/Gemini
pointed at a dead local proxy with no automatic way out.

Three orthogonal fixes, defense in depth:

- restart_app now awaits cleanup_before_exit() before app.restart().
  Since #4069 the ExitRequested handler intentionally defers restart
  requests to Tauri's default re-exec without custom cleanup, which is
  correct for same-DB restarts but not for this command's dir-change
  use case: only the old instance holds the backups needed to restore
  the taken-over live files, so it must restore them while its event
  loop is still alive.
- import_default_config refuses to import a live config that is under
  proxy takeover (placeholder detected), instead of persisting it as
  the current provider.
- restore_live_from_ssot_for_app validates that the current provider's
  settings_config does not itself contain takeover placeholders before
  writing it back; polluted SSOT now falls through to the placeholder
  cleanup fallback.

Regression tests cover the import guard and the no-backup recovery
path (the latter fails before this change by writing PROXY_MANAGED
back to live).
2026-06-11 21:32:24 +08:00

528 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings_for_frontend())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
let existing = crate::settings::get_settings();
let merged = merge_settings_for_save(settings, &existing);
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
Ok(true)
}
/// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
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<bool, String> {
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<Option<String>, 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<String>,
) -> Result<bool, String> {
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<bool, String> {
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<bool, String> {
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<crate::proxy::types::RectifierConfig, String> {
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<bool, String> {
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<crate::proxy::types::OptimizerConfig, String> {
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<bool, String> {
// 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<crate::proxy::types::CopilotOptimizerConfig, String> {
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<bool, String> {
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<crate::proxy::types::LogConfig, String> {
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<bool, String> {
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)
}