From 11b1ea23a558b5c6c82c2f2a00b973893332a905 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 13:31:58 +0800 Subject: [PATCH] fix(core): preserve installed plugins during core updates Overlay extracted core files onto the existing installation instead of replacing the entire cpa-core directory, preserving plugins and user data. --- src-tauri/src/core_runtime.rs | 84 +++++++++++++++++++++-------- src-tauri/src/tests/core_runtime.rs | 75 ++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index fa79a91..54b3274 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -180,7 +180,6 @@ pub(crate) async fn install_core_version_inner( let install_dir = core_install_dir()?; let base_dir = core_base_dir()?; let staging_dir = base_dir.join("cpa-core.staging"); - let backup_dir = base_dir.join("cpa-core.backup"); let download_dir = base_dir.join("cpa-core.download"); if current_core_status(None, None)?.running { @@ -231,7 +230,7 @@ pub(crate) async fn install_core_version_inner( }, )?; - replace_install_dir(&install_dir, &staging_dir, &backup_dir)?; + overlay_install_dir(&install_dir, &staging_dir)?; let _ = fs::remove_dir_all(&download_dir); Ok(CoreInstallResult { @@ -252,7 +251,6 @@ pub(crate) fn install_bundled_core_inner( let install_dir = core_install_dir()?; let base_dir = core_base_dir()?; let staging_dir = base_dir.join("cpa-core.staging"); - let backup_dir = base_dir.join("cpa-core.backup"); if current_core_status(None, None)?.running { return Err("CPA 内核正在运行,请先停止后再使用内置内核".to_string()); @@ -294,7 +292,7 @@ pub(crate) fn install_bundled_core_inner( installed_at_unix: unix_now(), }, )?; - replace_install_dir(&install_dir, &staging_dir, &backup_dir)?; + overlay_install_dir(&install_dir, &staging_dir)?; Ok(CoreInstallResult { version: info.version.clone(), @@ -1654,30 +1652,70 @@ pub(crate) fn reset_dir(path: &Path) -> Result<(), String> { fs::create_dir_all(path).map_err(|err| format!("创建目录失败 {}: {err}", path_to_string(path))) } -pub(crate) fn replace_install_dir( - install_dir: &Path, - staging_dir: &Path, - backup_dir: &Path, -) -> Result<(), String> { - if backup_dir.exists() { - fs::remove_dir_all(backup_dir).map_err(|err| format!("清理备份目录失败: {err}"))?; +pub(crate) fn overlay_install_dir(install_dir: &Path, staging_dir: &Path) -> Result<(), String> { + if !staging_dir.is_dir() { + return Err(format!( + "内核暂存目录不存在: {}", + path_to_string(staging_dir) + )); } - if install_dir.exists() { - fs::rename(install_dir, backup_dir) - .map_err(|err| format!("备份旧内核目录失败,请确认 CPA 内核未运行: {err}"))?; + if !install_dir.exists() { + return fs::rename(staging_dir, install_dir) + .map_err(|err| format!("安装新内核目录失败: {err}")); + } + if !install_dir.is_dir() { + return Err(format!( + "内核安装路径不是目录: {}", + path_to_string(install_dir) + )); } - if let Err(err) = fs::rename(staging_dir, install_dir) { - if backup_dir.exists() { - let _ = fs::rename(backup_dir, install_dir); + overlay_directory(staging_dir, install_dir)?; + fs::remove_dir_all(staging_dir).map_err(|err| format!("清理内核暂存目录失败: {err}")) +} + +fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String> { + for entry in fs::read_dir(source_dir) + .map_err(|err| format!("读取内核暂存目录失败 {}: {err}", path_to_string(source_dir)))? + { + let entry = entry.map_err(|err| format!("读取内核暂存条目失败: {err}"))?; + let source_path = entry.path(); + let target_path = target_dir.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|err| format!("读取内核暂存条目类型失败: {err}"))?; + + if file_type.is_dir() { + if target_path.exists() && !target_path.is_dir() { + return Err(format!( + "无法用内核目录覆盖同名文件: {}", + path_to_string(&target_path) + )); + } + fs::create_dir_all(&target_path).map_err(|err| { + format!( + "创建内核安装子目录失败 {}: {err}", + path_to_string(&target_path) + ) + })?; + overlay_directory(&source_path, &target_path)?; + } else if file_type.is_file() { + if target_path.exists() && !target_path.is_file() { + return Err(format!( + "无法用内核文件覆盖同名目录: {}", + path_to_string(&target_path) + )); + } + fs::copy(&source_path, &target_path).map_err(|err| { + format!("覆盖内核文件失败 {}: {err}", path_to_string(&target_path)) + })?; + } else { + return Err(format!( + "内核暂存目录包含不支持的条目: {}", + path_to_string(&source_path) + )); } - - return Err(format!("切换新内核目录失败: {err}")); - } - - if backup_dir.exists() { - fs::remove_dir_all(backup_dir).map_err(|err| format!("删除旧内核备份目录失败: {err}"))?; } Ok(()) diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index b172615..dc82cad 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -40,6 +40,81 @@ fn replacing_a_core_preserves_only_regular_bundled_assets() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn overlaying_a_core_updates_packaged_files_and_preserves_plugins() { + let root = agent_test_home("core-overlay-preserves-plugins"); + let install_dir = root.join("cpa-core"); + let staging_dir = root.join("cpa-core.staging"); + fs::create_dir_all(install_dir.join("plugins/custom-router")).unwrap(); + fs::create_dir_all(staging_dir.join("plugins/bundled-router")).unwrap(); + fs::create_dir_all(staging_dir.join("runtime")).unwrap(); + fs::write(install_dir.join(core_binary_name()), b"old core").unwrap(); + fs::write(install_dir.join("README.md"), b"old readme").unwrap(); + fs::write(install_dir.join("user-data.json"), b"user data").unwrap(); + fs::write( + install_dir.join("plugins/custom-router/plugin.js"), + b"custom plugin", + ) + .unwrap(); + fs::write(staging_dir.join(core_binary_name()), b"new core").unwrap(); + fs::write(staging_dir.join("README.md"), b"new readme").unwrap(); + fs::write( + staging_dir.join("plugins/bundled-router/plugin.js"), + b"bundled plugin", + ) + .unwrap(); + fs::write(staging_dir.join("runtime/default.json"), b"new runtime").unwrap(); + + overlay_install_dir(&install_dir, &staging_dir).unwrap(); + + assert_eq!( + fs::read(install_dir.join(core_binary_name())).unwrap(), + b"new core" + ); + assert_eq!( + fs::read(install_dir.join("README.md")).unwrap(), + b"new readme" + ); + assert_eq!( + fs::read(install_dir.join("runtime/default.json")).unwrap(), + b"new runtime" + ); + assert_eq!( + fs::read(install_dir.join("plugins/custom-router/plugin.js")).unwrap(), + b"custom plugin" + ); + assert_eq!( + fs::read(install_dir.join("plugins/bundled-router/plugin.js")).unwrap(), + b"bundled plugin" + ); + assert_eq!( + fs::read(install_dir.join("user-data.json")).unwrap(), + b"user data" + ); + assert!(!staging_dir.exists()); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn installing_a_core_into_a_missing_directory_moves_the_complete_staging_tree() { + let root = agent_test_home("core-overlay-first-install"); + let install_dir = root.join("cpa-core"); + let staging_dir = root.join("cpa-core.staging"); + fs::create_dir_all(&staging_dir).unwrap(); + fs::write(staging_dir.join(core_binary_name()), b"new core").unwrap(); + fs::write(staging_dir.join(CORE_EXAMPLE_CONFIG_FILE), b"port: 8317\n").unwrap(); + + overlay_install_dir(&install_dir, &staging_dir).unwrap(); + + assert_eq!( + fs::read(install_dir.join(core_binary_name())).unwrap(), + b"new core" + ); + assert!(install_dir.join(CORE_EXAMPLE_CONFIG_FILE).is_file()); + assert!(!staging_dir.exists()); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn replacing_a_core_migrates_old_fields_into_the_new_template() { let root = agent_test_home("core-config-migrate");