mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
7bb59fa5a6
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).
528 lines
18 KiB
Rust
528 lines
18 KiB
Rust
#![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)
|
||
}
|