diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 24a1165..e21bf69 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -5486,6 +5486,26 @@ fn with_file_in_use_hint(message: String) -> String { ) } +/// 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 { + if !everest::is_installer_native_crash(&message) { + return message; + } + format!( + "{message}\n\n这不是游戏文件坏了:安装程序跑的时候被系统里的东西打断了(CeleMod 已经自动重装过一遍,还是崩)。\ + 这类崩溃常常是偶发的,再点一次安装往往就过了;连着几次都崩,就临时关掉杀毒软件/安全卫士的实时防护、\ + 显卡驱动的游戏内覆盖层再装;还是不行就手动装:去 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 +5546,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 +5740,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 +6363,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; 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-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}; 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[], ) => {