From 413052129d0f71ef79b8cd44413c3f2f57fb1b74 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:00:03 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(everest):=20=E5=AE=89=E8=A3=85=E5=99=A8?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=B4=A9=E6=BA=83=E6=97=B6=E7=BB=99=E5=87=BA?= =?UTF-8?q?=E8=83=BD=E7=85=A7=E5=81=9A=E7=9A=84=E6=8F=90=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E5=8F=AA=E7=94=A9=E4=B8=80=E6=AE=B5?= =?UTF-8?q?=E6=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiniInstaller 有时不是报安装错误,而是直接死在 .NET 运行时自己的原生代码里 (AccessViolationException 打在 relinker 的 String.StartsWith → Globalization P/Invoke 上)。这段栈对用户没有用,还会让人以为游戏或 CeleMod 坏了。 CELEMOD 侧确认过的事实:MiniInstaller 的 StandardMode 是在本进程里单线程调 MonoMod 的(DepCalls.RunMonoMod → MonoModder.AutoPatch),MonoModder 自己没有 任何 Parallel/Task,官方 Everest 也没有并行 relink/并行加载开关(那两个 EVEREST_PARALLEL_* 是 EverestUltra 的),所以这次崩溃不是重链竞态,而是进程里 被别的东西打坏了原生内存/运行时:常见是杀毒软件、显卡驱动的游戏内覆盖层之类 注入到每个进程的东西,或者游戏目录里那份 piton-runtime 被打坏。 安装失败时按栈里的特征串认出这类崩溃,在原始错误后面补一条能照做的建议 (先关实时防护和游戏内覆盖层再装一次;不行就手动解压 Main.zip 跑 MiniInstaller-win64.exe),本地包安装走同一个提示。其他安装失败原样返回。 --- src-tauri/src/backend.rs | 67 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 24a1165..9eb1001 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -5486,6 +5486,32 @@ fn with_file_in_use_hint(message: String) -> String { ) } +/// MiniInstaller sometimes dies inside the .NET runtime's own native code +/// instead of reporting an install error: an AccessViolationException straight +/// out of the relinker (`String.StartsWith` -> the Globalization P/Invoke). That +/// crash comes from a native component that has nothing to do with the game +/// (anti-virus / GPU driver overlays injecting into every process, or a damaged +/// runtime next to the game), and MiniInstaller runs MonoMod single-threaded, so +/// the install itself was not racing. The raw stack makes people think their +/// game or CeleMod is broken, so point at what actually helps and at the manual +/// install as the fallback. +fn with_installer_crash_hint(message: String) -> String { + const CRASH_MARKERS: [&str; 3] = [ + "System.AccessViolationException", + "Attempted to read or write protected memory", + "尝试读取或写入受保护的内存", + ]; + if !CRASH_MARKERS.iter().any(|marker| message.contains(marker)) { + return message; + } + format!( + "{message}\n\n这不是游戏文件坏了:安装程序在跑的时候被系统里的东西打断了(常见是杀毒软件/安全卫士的实时防护,\ + 或者显卡驱动带的游戏内覆盖层)。先临时关掉它们再装一次;还是不行就手动装:\ + 去 everestapi.github.io 下载 Everest 的 Main.zip,解压到游戏目录(和 Celeste.exe 放在一起),\ + 再双击运行里面的 MiniInstaller-win64.exe。" + ) +} + #[tauri::command] fn download_and_install_everest(game_path: String, url: String, on_event: Channel) { std::thread::spawn(move || { @@ -5526,7 +5552,7 @@ fn download_and_install_everest(game_path: String, url: String, on_event: Channe ), Err(error) => { let error = logged_error(format!("Failed to install Everest: {error:#}")); - let error = with_file_in_use_hint(error); + let error = with_installer_crash_hint(with_file_in_use_hint(error)); send_event( &on_event, vec![serde_json::json!("Failed"), serde_json::json!(error)], @@ -5720,6 +5746,7 @@ fn install_local_packages( let error = logged_error(format!( "Failed to install local package {file_name}: {error:#}" )); + let error = with_installer_crash_hint(error); LocalPackageInstallResult { file: file_name, package_type, @@ -6342,6 +6369,44 @@ mod keyboard_input_tests { } } +#[cfg(test)] +mod everest_install_hint_tests { + use super::with_installer_crash_hint; + + #[test] + fn appends_a_hint_to_an_installer_native_crash() { + let message = "Failed to install Everest: Command failed with error: Fatal error. \ + System.AccessViolationException: Attempted to read or write protected \ + memory. at MonoMod.MonoModder.FindType"; + let hinted = with_installer_crash_hint(message.to_string()); + assert!( + hinted.starts_with(message), + "the install error must stay first" + ); + assert!(hinted.contains("everestapi.github.io")); + assert!(hinted.contains("MiniInstaller-win64.exe")); + } + + #[test] + fn recognizes_the_localized_crash_message() { + let hinted = with_installer_crash_hint( + "System.AccessViolationException: 尝试读取或写入受保护的内存。".to_string(), + ); + assert!(hinted.contains("杀毒软件")); + } + + #[test] + fn leaves_other_install_failures_alone() { + for message in [ + "Failed to install Everest: Failed to replace C:\\Celeste\\Celeste.dll. Close Celeste", + "Failed to install Everest: The zip is not an Everest install package for this platform", + "Failed to install local package mod.zip: unsupported zip", + ] { + assert_eq!(with_installer_crash_hint(message.to_string()), message); + } + } +} + #[cfg(test)] mod legacy_loader_tests { use super::configure_legacy_loader_environment; From baf6d3fc4a139e451a6c7fa0be952bf831691af0 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:07:17 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(everest):=20=E5=AE=89=E8=A3=85=E5=99=A8?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=B4=A9=E6=BA=83=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=A3=85=E4=B8=80=E6=AC=A1=EF=BC=8C=E5=B9=B6=E8=AE=A9?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=99=A8=E8=BF=9B=E7=A8=8B=E9=81=BF=E5=BC=80?= =?UTF-8?q?=20ICU=20=E6=AF=94=E8=BE=83=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈这类崩溃是偶发的:同一台机器、同样的步骤,之前成功过一次。所以它的 性质是「偶发 + 崩在运行时原生层」,不是确定性的环境损坏。 - everest.rs:抽出 extract_everest_archive,安装失败且错误串带原生崩溃特征 (is_installer_native_crash:AccessViolationException / 受保护内存中英文)时, 重新解压一遍再跑一次 MiniInstaller(崩溃那次会把游戏目录留在半装状态,不 重解压直接重跑不安全),只重试一次,两次都崩才把错误抛给用户。 - everest.rs:在 Windows 上给安装器子进程设 DOTNET_SYSTEM_GLOBALIZATION_USENLS=1。 MonoMod 在 FindType 里用文化相关的 String.StartsWith 比依赖名,.NET 在非 en* 区域设置下会走 ICU(dotnet/runtime#132702 是同一个栈,运行时团队判定该路径是 外部堆破坏的最早受害者);让安装器进程用 NLS 只影响这些 ASCII 前缀的比较结果 (完全一致),对子进程来说是安全的默认值。 - backend.rs:提示文案改成「已经自动重装过一遍还是崩」,并把重试放在第一位。 cargo test --lib: 119 passed(新增 3 个:崩溃特征识别、失败后确实重试并成功、连续崩两次不循环)。 --- src-tauri/src/backend.rs | 32 +++---- src-tauri/src/everest.rs | 198 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 198 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 9eb1001..e21bf69 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -5486,29 +5486,23 @@ fn with_file_in_use_hint(message: String) -> String { ) } -/// MiniInstaller sometimes dies inside the .NET runtime's own native code -/// instead of reporting an install error: an AccessViolationException straight -/// out of the relinker (`String.StartsWith` -> the Globalization P/Invoke). That -/// crash comes from a native component that has nothing to do with the game -/// (anti-virus / GPU driver overlays injecting into every process, or a damaged -/// runtime next to the game), and MiniInstaller runs MonoMod single-threaded, so -/// the install itself was not racing. The raw stack makes people think their -/// game or CeleMod is broken, so point at what actually helps and at the manual -/// install as the fallback. +/// MiniInstaller died in native code (again, after the automatic retry in +/// `everest::install_everest_archive_with_steps`). The stack belongs to the +/// runtime, not to the game: `MonoModder.FindType` compares dependency names +/// with a culture-sensitive `String.StartsWith`, .NET routes that through ICU, +/// and dotnet/runtime#132702 is the same crash with the runtime team's verdict +/// that this path is the earliest *victim* of heap corruption written elsewhere. +/// The raw stack makes people think their game or CeleMod is broken, so point at +/// what actually helps and at the manual install as the fallback. fn with_installer_crash_hint(message: String) -> String { - const CRASH_MARKERS: [&str; 3] = [ - "System.AccessViolationException", - "Attempted to read or write protected memory", - "尝试读取或写入受保护的内存", - ]; - if !CRASH_MARKERS.iter().any(|marker| message.contains(marker)) { + if !everest::is_installer_native_crash(&message) { return message; } format!( - "{message}\n\n这不是游戏文件坏了:安装程序在跑的时候被系统里的东西打断了(常见是杀毒软件/安全卫士的实时防护,\ - 或者显卡驱动带的游戏内覆盖层)。先临时关掉它们再装一次;还是不行就手动装:\ - 去 everestapi.github.io 下载 Everest 的 Main.zip,解压到游戏目录(和 Celeste.exe 放在一起),\ - 再双击运行里面的 MiniInstaller-win64.exe。" + "{message}\n\n这不是游戏文件坏了:安装程序跑的时候被系统里的东西打断了(CeleMod 已经自动重装过一遍,还是崩)。\ + 这类崩溃常常是偶发的,再点一次安装往往就过了;连着几次都崩,就临时关掉杀毒软件/安全卫士的实时防护、\ + 显卡驱动的游戏内覆盖层再装;还是不行就手动装:去 everestapi.github.io 下载 Everest 的 Main.zip,\ + 解压到游戏目录(和 Celeste.exe 放在一起),再双击运行里面的 MiniInstaller-win64.exe。" ) } diff --git a/src-tauri/src/everest.rs b/src-tauri/src/everest.rs index f209364..7643e8b 100644 --- a/src-tauri/src/everest.rs +++ b/src-tauri/src/everest.rs @@ -468,6 +468,40 @@ pub fn is_everest_ultra(game_path: &Path) -> bool { }) } +/// The install has to survive a MiniInstaller process that dies in native code +/// (see [`is_installer_native_crash`]). On Windows that crash keeps landing in +/// the ICU path .NET uses for culture-sensitive string comparison: +/// `String.StartsWith` -> `CompareInfo.IsPrefix` -> `GlobalizationNative_StartsWith`. +/// MonoMod hits it in `MonoModder.FindType` (`CrawlDependencies` checks whether a +/// dependency name starts with `"System.Private"`), and dotnet/runtime#132702 is +/// the same stack: a Chinese (non-`en*`) system locale is what sends these ASCII +/// prefixes through ICU at all, and the runtime team's verdict there was that the +/// path is the earliest *victim* of heap corruption written elsewhere. +/// +/// Asking the runtime for NLS instead of ICU for the installer process only +/// changes how those ASCII prefixes are compared (identical results), so it is a +/// safe default for a child process we own. +#[cfg(target_os = "windows")] +const INSTALLER_GLOBALIZATION_ENV: [(&str, &str); 1] = + [("DOTNET_SYSTEM_GLOBALIZATION_USENLS", "1")]; +#[cfg(not(target_os = "windows"))] +const INSTALLER_GLOBALIZATION_ENV: [(&str, &str); 0] = []; + +/// MiniInstaller sometimes fails with a native fault instead of an install +/// error: the .NET runtime prints `Fatal error. System.AccessViolationException` +/// to stderr and fails the process fast. Those runs are *intermittent* (the same +/// machine and the same steps succeed on a retry), and they leave the game +/// directory half-updated, so the useful reaction is to run the whole install +/// again once instead of telling the user their game is broken. +pub fn is_installer_native_crash(message: &str) -> bool { + const CRASH_MARKERS: [&str; 3] = [ + "System.AccessViolationException", + "Attempted to read or write protected memory", + "尝试读取或写入受保护的内存", + ]; + CRASH_MARKERS.iter().any(|marker| message.contains(marker)) +} + fn run_command( installer_path: PathBuf, step_label: &str, @@ -480,6 +514,7 @@ fn run_command( let mut cmd = Command::new(&installer_path); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); + cmd.envs(INSTALLER_GLOBALIZATION_ENV); #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; #[cfg(target_os = "windows")] @@ -592,19 +627,12 @@ pub fn is_everest_install_archive(path: &Path) -> anyhow::Result { Ok(has_installer) } -fn install_everest_archive_with_steps( +fn extract_everest_archive( game_path: &Path, archive_path: &Path, extract_step: &str, - installer_step: &str, progress_callback: &mut dyn FnMut(String, f32), ) -> anyhow::Result<()> { - #[cfg(target_os = "android")] - crate::android::can_install()?; - if !is_everest_install_archive(archive_path)? { - bail!("The zip is not an Everest install package for this platform"); - } - progress_callback(extract_step.to_string(), 0.0); let mut archive = zip::ZipArchive::new(std::fs::File::open(archive_path)?)?; @@ -652,12 +680,50 @@ fn install_everest_archive_with_steps( } } + Ok(()) +} + +fn install_everest_archive_with_steps( + game_path: &Path, + archive_path: &Path, + extract_step: &str, + installer_step: &str, + progress_callback: &mut dyn FnMut(String, f32), +) -> anyhow::Result<()> { + #[cfg(target_os = "android")] + crate::android::can_install()?; + if !is_everest_install_archive(archive_path)? { + bail!("The zip is not an Everest install package for this platform"); + } + + extract_everest_archive(game_path, archive_path, extract_step, progress_callback)?; + + let installer_path = game_path.join(installer_name()?); progress_callback(installer_step.to_string(), 0.0); - run_command( - game_path.join(installer_name()?), - installer_step, - progress_callback, - ) + let error = match run_command(installer_path.clone(), installer_step, progress_callback) { + Ok(()) => return Ok(()), + Err(error) => error, + }; + + // A native crash in MiniInstaller is intermittent: the same machine installs + // fine on the next attempt, and the failing run left the game directory + // half-updated. Re-extract and run the installer once more before giving up, + // which is exactly what the user would otherwise do by hand. + if !is_installer_native_crash(&format!("{error:#}")) { + return Err(error); + } + + progress_callback( + format!("{installer_step}: 安装器崩溃,正在重新解压并重试一次"), + 0.0, + ); + extract_everest_archive(game_path, archive_path, extract_step, progress_callback)?; + progress_callback(installer_step.to_string(), 0.0); + run_command(installer_path, installer_step, progress_callback).map_err(|second| { + second.context(format!( + "安装器连续两次在原生代码里崩溃(第一次:{error:#})" + )) + }) } pub fn install_everest_archive( @@ -704,6 +770,112 @@ pub fn download_and_install_everest( ) } +#[cfg(test)] +mod installer_retry_tests { + use super::is_installer_native_crash; + use std::fs; + use std::io::Write; + use std::path::Path; + + fn write_everest_archive(path: &Path, installer_name: &str, script: &str) { + let file = fs::File::create(path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + for (name, contents) in [ + (format!("main/{installer_name}"), script.as_bytes()), + ("main/Celeste.Mod.mm.dll".to_string(), b"dll".as_slice()), + ] { + writer + .start_file(name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(contents).unwrap(); + } + writer.finish().unwrap(); + } + + #[test] + fn recognizes_the_runtime_native_crash() { + assert!(is_installer_native_crash( + "Failed to install Everest: Command failed with error: Fatal error. \ + System.AccessViolationException: Attempted to read or write protected memory." + )); + assert!(is_installer_native_crash( + "System.AccessViolationException: 尝试读取或写入受保护的内存。" + )); + assert!(!is_installer_native_crash( + "Failed to replace C:\\Celeste\\Celeste.dll. Close Celeste and any program using this file" + )); + } + + #[cfg(unix)] + #[test] + fn retries_the_whole_install_once_after_a_native_crash() { + use super::install_everest_archive_with_steps; + + let root = std::env::temp_dir().join(format!("celemod-retry-{}", std::process::id())); + let game = root.join("game"); + fs::create_dir_all(&game).unwrap(); + let archive = root.join("everest.zip"); + write_everest_archive( + &archive, + super::installer_name().unwrap(), + "#!/bin/sh\nn=$(cat attempts 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > attempts\n\ + if [ \"$n\" -lt 2 ]; then\n\ + echo 'Fatal error. System.AccessViolationException: Attempted to read or write protected memory.' >&2\n\ + exit 134\nfi\n", + ); + + let result = install_everest_archive_with_steps( + &game, + &archive, + "extract", + "install", + &mut |_, _| {}, + ); + assert!( + result.is_ok(), + "the second attempt must be used: {result:?}" + ); + assert_eq!( + fs::read_to_string(game.join("attempts")).unwrap().trim(), + "2" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn reports_an_installer_that_keeps_crashing_instead_of_looping() { + use super::install_everest_archive_with_steps; + + let root = std::env::temp_dir().join(format!("celemod-retry-fail-{}", std::process::id())); + let game = root.join("game"); + fs::create_dir_all(&game).unwrap(); + let archive = root.join("everest.zip"); + write_everest_archive( + &archive, + super::installer_name().unwrap(), + "#!/bin/sh\nn=$(cat attempts 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > attempts\n\ + echo 'Fatal error. System.AccessViolationException: Attempted to read or write protected memory.' >&2\n\ + exit 134\n", + ); + + let error = install_everest_archive_with_steps( + &game, + &archive, + "extract", + "install", + &mut |_, _| {}, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("连续两次")); + assert_eq!( + fs::read_to_string(game.join("attempts")).unwrap().trim(), + "2" + ); + fs::remove_dir_all(root).unwrap(); + } +} + #[cfg(test)] mod tests { use super::{CatalogWait, is_everest_ultra, wait_for_catalog}; From c926db80c7dfabbf6006787bcb6b1e5ca7df9dd4 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:02:22 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(manage):=20=E5=A7=8B=E7=BB=88=E5=BC=80?= =?UTF-8?q?=E5=90=AF/=E5=90=8C=E5=90=8D=E9=87=8D=E5=A4=8D=E7=9A=84=20Mod?= =?UTF-8?q?=20=E4=B8=8D=E5=86=8D=E5=85=B3=E4=B8=8D=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理页的状态开关对两类 Mod 是死按键:点了没反应,也没有任何提示, 用户只能看到它一直留在启用列表里。 - 始终开启(右键或长按状态按钮设的)Mod 会被静默过滤掉,而且这个标记 存在 localStorage 里,装回同一个 Mod 也会继承。现在点关闭会说明原因, 并可以一键「取消始终开启并禁用」;每行的「…」菜单也加了同样的开关, 触屏用户不必再猜右键。 - 未启用预设模式时,禁用只把状态按钮上显示的那个文件写进 blacklist.txt。 同名重复安装(文件夹 + 压缩包)时另一个文件照旧被加载,刷新后又显示 「已启用」。现在禁用会覆盖该 Mod 名下的全部文件;启用仍只放开显示的 那个文件。 测试:src/alwaysOnSwitch.test.ts(splitAlwaysOnNames / switchBlacklistFiles), blacklist.rs 的 direct_blacklist_disable_covers_every_duplicate_file。 --- src-tauri/src/blacklist.rs | 34 ++++++ src/celemod-ui/locales/en-US.json | 7 +- src/celemod-ui/locales/zh-CN.json | 7 +- src/celemod-ui/package.json | 1 + src/celemod-ui/src/alwaysOnSwitch.test.ts | 111 ++++++++++++++++++ src/celemod-ui/src/routes/Manage.tsx | 136 +++++++++++++++++----- src/celemod-ui/src/stores/manage.ts | 70 +++++++++++ 7 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 src/celemod-ui/src/alwaysOnSwitch.test.ts diff --git a/src-tauri/src/blacklist.rs b/src-tauri/src/blacklist.rs index 888e1ce..dea6fbb 100644 --- a/src-tauri/src/blacklist.rs +++ b/src-tauri/src/blacklist.rs @@ -1184,6 +1184,40 @@ mod tests { fs::remove_dir_all(game_path).unwrap(); } + #[test] + fn direct_blacklist_disable_covers_every_duplicate_file() { + let game_path = test_game_path("direct-duplicate-disable"); + write_directory_mod(&game_path, "Duplicate", "Duplicate.Mod", "1.0.0"); + write_directory_mod(&game_path, "Duplicate-Old", "Duplicate.Mod", "0.9.0"); + + // blacklist.txt works per file, so the manage page passes every + // installed file of the Mod name: writing only the file it shows would + // leave the same-name duplicate loading and the Mod would look enabled + // again after the next reload. + switch_direct_blacklist( + &game_path, + &["Duplicate".to_string(), "Duplicate-Old".to_string()], + false, + ) + .unwrap(); + let direct = get_direct_blacklist_profile(&game_path).unwrap(); + assert!( + !direct + .enabled_mods + .contains(&"Duplicate.Mod".to_string()) + ); + + // Enabling brings the Mod back through the file the page shows. + switch_direct_blacklist(&game_path, &["Duplicate".to_string()], true).unwrap(); + let direct = get_direct_blacklist_profile(&game_path).unwrap(); + assert!( + direct + .enabled_mods + .contains(&"Duplicate.Mod".to_string()) + ); + fs::remove_dir_all(game_path).unwrap(); + } + #[test] fn profile_toggle_reenables_the_only_remaining_duplicate_file() { let game_path = test_game_path("toggle-remaining-duplicate"); diff --git a/src/celemod-ui/locales/en-US.json b/src/celemod-ui/locales/en-US.json index eab511a..b299ea5 100644 --- a/src/celemod-ui/locales/en-US.json +++ b/src/celemod-ui/locales/en-US.json @@ -644,5 +644,10 @@ "跳过下载,离线继续": "Skip download and continue offline", "模组目录离线模式": "Mod catalog offline", "仍可启动游戏和管理本地模组": "You can still play and manage installed mods.", - "重新连接": "Reconnect" + "重新连接": "Reconnect", + "该 Mod 已设为始终开启": "This Mod is set to always on", + "始终开启的 Mod 不会被开关或「禁用全部」关掉。要继续禁用,请同时取消始终开启:": "Always-on Mods ignore the switch and \"Disable All\". Clear always-on to disable them:", + "取消始终开启并禁用": "Clear always-on and disable", + "取消始终开启": "Clear always on", + "设为始终开启": "Set always on" } diff --git a/src/celemod-ui/locales/zh-CN.json b/src/celemod-ui/locales/zh-CN.json index b81c48f..fdd0e3f 100644 --- a/src/celemod-ui/locales/zh-CN.json +++ b/src/celemod-ui/locales/zh-CN.json @@ -646,5 +646,10 @@ "跳过下载,离线继续": "跳过下载,离线继续", "模组目录离线模式": "模组目录离线模式", "仍可启动游戏和管理本地模组": "仍可启动游戏和管理本地模组", - "重新连接": "重新连接" + "重新连接": "重新连接", + "该 Mod 已设为始终开启": "该 Mod 已设为始终开启", + "始终开启的 Mod 不会被开关或「禁用全部」关掉。要继续禁用,请同时取消始终开启:": "始终开启的 Mod 不会被开关或「禁用全部」关掉。要继续禁用,请同时取消始终开启:", + "取消始终开启并禁用": "取消始终开启并禁用", + "取消始终开启": "取消始终开启", + "设为始终开启": "设为始终开启" } diff --git a/src/celemod-ui/package.json b/src/celemod-ui/package.json index cbed3c0..a3e88f9 100644 --- a/src/celemod-ui/package.json +++ b/src/celemod-ui/package.json @@ -17,6 +17,7 @@ "test:crash-trace": "node --import tsx --test src/components/crashTrace.test.ts", "test:deep-link": "node --import tsx --test src/deepLink.test.ts", "test:mod-updates": "node --import tsx --test src/modUpdates.test.ts", + "test:always-on": "node --import tsx --test src/alwaysOnSwitch.test.ts", "test:update-notes": "node --import tsx --test src/components/UpdateNotes.test.tsx" }, "dependencies": { diff --git a/src/celemod-ui/src/alwaysOnSwitch.test.ts b/src/celemod-ui/src/alwaysOnSwitch.test.ts new file mode 100644 index 0000000..933f3ab --- /dev/null +++ b/src/celemod-ui/src/alwaysOnSwitch.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isAlwaysOnPackage, + splitAlwaysOnNames, + switchBlacklistFiles, + type ManageNode, +} from "./stores/manage"; + +const node = (name: string, options: Partial = {}): ManageNode => ({ + name, + id: "0", + enabled: true, + enabledByName: true, + version: "1.0.0", + file: `${name}.zip`, + size: 0, + modifiedAt: 0, + isDirectory: false, + dependencies: [], + dependedBy: [], + duplicateFiles: [], + meta: null, + ...options, +}); + +const nodesOf = (...entries: ManageNode[]) => + Object.fromEntries(entries.map((entry) => [entry.name, entry])); + +test("an always-on Mod is reported instead of being switched silently", () => { + const nodes = nodesOf(node("AlwaysOn"), node("Free")); + const { switchable, blocked } = splitAlwaysOnNames({ + names: ["AlwaysOn", "Free"], + nodes, + alwaysOnMods: ["AlwaysOn"], + }); + assert.deepEqual(blocked, ["AlwaysOn"]); + assert.deepEqual(switchable, ["Free"]); +}); + +test("always-on is matched by file, so a renamed Mod stays blocked", () => { + // The stored name no longer exists (the Mod was deleted and reinstalled + // under another name) but the file is the same one. + const nodes = nodesOf(node("Renamed", { file: "Renamed.zip" })); + const { blocked } = splitAlwaysOnNames({ + names: ["Renamed"], + nodes, + alwaysOnMods: ["OldName"], + }); + // A name that is not installed cannot mark the file. + assert.deepEqual(blocked, []); + + const shared = nodesOf( + node("Kept", { file: "Kept.zip" }), + node("Stale", { file: "Kept.zip" }), + ); + assert.equal(isAlwaysOnPackage("Kept", shared, ["Stale"]), true); +}); + +test("an unknown always-on name does not block anything", () => { + const nodes = nodesOf(node("Plain")); + assert.equal(isAlwaysOnPackage("Plain", nodes, ["DeletedLongAgo"]), false); + assert.equal(isAlwaysOnPackage("Missing", nodes, ["DeletedLongAgo"]), false); +}); + +test("disabling a Mod blacklists every file of that name", () => { + const nodes = nodesOf( + node("Duplicate", { + file: "Duplicate.zip", + duplicateFiles: [ + { + file: "Duplicate.zip", + version: "1.0.0", + size: 0, + modifiedAt: 0, + isDirectory: false, + enabled: true, + }, + { + file: "Duplicate Folder", + version: "0.9.0", + size: 0, + modifiedAt: 0, + isDirectory: true, + enabled: true, + }, + ], + }), + ); + assert.deepEqual( + switchBlacklistFiles({ names: ["Duplicate"], nodes, enabled: false }), + ["Duplicate.zip", "Duplicate Folder"], + ); + // Enabling only lifts the file the manage tree shows. + assert.deepEqual( + switchBlacklistFiles({ names: ["Duplicate"], nodes, enabled: true }), + ["Duplicate.zip"], + ); +}); + +test("a Mod without duplicates switches its single file", () => { + const nodes = nodesOf(node("Plain")); + assert.deepEqual( + switchBlacklistFiles({ names: ["Plain"], nodes, enabled: false }), + ["Plain.zip"], + ); + assert.deepEqual( + switchBlacklistFiles({ names: ["Plain"], nodes, enabled: true }), + ["Plain.zip"], + ); +}); diff --git a/src/celemod-ui/src/routes/Manage.tsx b/src/celemod-ui/src/routes/Manage.tsx index 469e99f..bd8e09d 100644 --- a/src/celemod-ui/src/routes/Manage.tsx +++ b/src/celemod-ui/src/routes/Manage.tsx @@ -48,6 +48,9 @@ import { ManageCatalogMeta, ManageNode, collectSwitchNames, + isAlwaysOnPackage, + splitAlwaysOnNames, + switchBlacklistFiles, collectDuplicateFixTargets, profileDisabledFilesAfterSwitch, type DuplicateFixTarget, @@ -838,9 +841,7 @@ const ManageTreeNode = ({ const hasDependencies = visibleDependencies.length > 0; const cycle = path.includes(name); const health = getDependencyHealth(name, nodes, actions.checkOptional); - const isAlwaysOn = actions.alwaysOnMods.some( - (alwaysOnName) => nodes[alwaysOnName]?.file === node.file, - ); + const isAlwaysOn = isAlwaysOnPackage(name, nodes, actions.alwaysOnMods); const hasUpdate = actions.updateNames.has(name); const instanceId = JSON.stringify([...path, name]); const menuOpen = openMenuName === instanceId; @@ -1055,6 +1056,15 @@ const ManageTreeNode = ({ ? _i18n.t("取消在 Mod 设置内置顶") : _i18n.t("在 Mod 设置内置顶")} + + + + + ); + }); + }, + [alwaysOnMods, batchSwitch, nodes, setAlwaysOnMods], + ); + const switchNodes = useCallback( (input: string | string[], enabled: boolean, recursive = true) => { const names = Array.isArray(input) ? input : [input]; + if (!enabled) { + const { blocked } = splitAlwaysOnNames({ names, nodes, alwaysOnMods }); + if (blocked.length > 0) { + promptAlwaysOnDisable(blocked); + return; + } + } batchSwitch( collectSwitchNames({ names, @@ -1555,10 +1625,12 @@ export const Manage = () => { }, [ orphanActionTypes, + alwaysOnMods, autoToggleDependencies, autoToggleOptionalDependencies, batchSwitch, nodes, + promptAlwaysOnDisable, ], ); diff --git a/src/celemod-ui/src/stores/manage.ts b/src/celemod-ui/src/stores/manage.ts index 7f9fc37..bbb0eb7 100644 --- a/src/celemod-ui/src/stores/manage.ts +++ b/src/celemod-ui/src/stores/manage.ts @@ -149,6 +149,76 @@ export const profileDisabledFilesAfterSwitch = ( return disabledFiles.filter((file) => !restoredFiles.has(file.toLowerCase())); }; +/** + * Always-on Mods are matched by file, the way the manage tree marks them, so a + * name that was kept while its file was replaced still counts. + */ +export const isAlwaysOnPackage = ( + name: string, + nodes: Record, + alwaysOnMods: readonly string[], +): boolean => { + const file = nodes[name]?.file; + if (!file) return false; + return alwaysOnMods.some( + (alwaysOnName) => nodes[alwaysOnName]?.file === file, + ); +}; + +/** + * Splits the names a switch wants to touch into the ones that can be switched + * and the ones that keep their state because they are marked always-on. + * Always-on Mods ignore the state button and "disable all" on purpose, so the + * caller has to tell the user about the blocked names - otherwise the button + * looks like it does nothing. + */ +export const splitAlwaysOnNames = ({ + names, + nodes, + alwaysOnMods, +}: { + names: readonly string[]; + nodes: Record; + alwaysOnMods: readonly string[]; +}): { switchable: string[]; blocked: string[] } => { + const switchable: string[] = []; + const blocked: string[] = []; + for (const name of names) { + if (isAlwaysOnPackage(name, nodes, alwaysOnMods)) blocked.push(name); + else switchable.push(name); + } + return { switchable, blocked }; +}; + +/** + * Files `blacklist.txt` has to change for a switch. Disabling covers every + * installed file of a Mod name: a same-name duplicate that is left out would + * keep loading, so the Mod would look enabled again after the next reload. + * Enabling only has to lift the file the manage tree shows. + */ +export const switchBlacklistFiles = ({ + names, + nodes, + enabled, +}: { + names: readonly string[]; + nodes: Record; + enabled: boolean; +}): string[] => { + const files: string[] = []; + for (const name of names) { + const node = nodes[name]; + if (!node) continue; + if (enabled) { + files.push(node.file); + continue; + } + const duplicates = node.duplicateFiles.map((file) => file.file); + files.push(...(duplicates.length > 0 ? duplicates : [node.file])); + } + return files; +}; + export const normalizeManageDependencies = ( dependencies: readonly ManageDependency[], ) => {