diff --git a/Cargo.lock b/Cargo.lock index caae7fa..b232fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,6 +520,7 @@ dependencies = [ "whoami", "winapi", "window-vibrancy 0.8.0", + "xxhash-rust", "zip", ] @@ -6401,6 +6402,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f961308..d45edd6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ pbkdf2 = "0.12" rand = "0.8" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } sha2 = "0.10" +xxhash-rust = { version = "0.8", features = ["xxh64"] } url = "2" webpki-roots = "1" whoami = "1" diff --git a/src-tauri/src/archive_hash.rs b/src-tauri/src/archive_hash.rs new file mode 100644 index 0000000..edb8dae --- /dev/null +++ b/src-tauri/src/archive_hash.rs @@ -0,0 +1,149 @@ +//! xxHash64 of Mod archives. +//! +//! Everest (and therefore Olympus) decides whether an installed Mod still +//! matches the newest released file by comparing the xxHash64 of the installed +//! archive with the hashes listed in the Mod update database. Comparing the +//! `everest.yaml` version instead misses releases whose archive keeps the old +//! or a wrong version number (aonHelper 0.19.8 still declares 0.19.7), so +//! CeleMod uses the same hash as well. +//! +//! Hashing reads the whole archive, so results are cached and reused while the +//! file keeps its size and modification time. + +use std::{ + collections::HashMap, + fs::{self, File}, + io::Read, + path::{Path, PathBuf}, +}; + +use anyhow::Context; +use lazy_static::lazy_static; +use parking_lot::Mutex; + +/// Hashes are streamed so that large Mod archives never sit in memory. +const BUFFER_SIZE: usize = 1 << 20; + +#[derive(Clone, PartialEq, Eq)] +struct CachedHash { + size: u64, + modified_at: u64, + hash: String, +} + +lazy_static! { + static ref HASH_CACHE: Mutex> = Mutex::new(HashMap::new()); +} + +/// Lowercase hex xxHash64 of a file, matching `Everest.GetChecksum` and the +/// `xxHash` entries of the Mod update database. +pub fn hash_file(path: &Path) -> anyhow::Result { + let mut hasher = xxhash_rust::xxh64::Xxh64::new(0); + let mut file = + File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + let mut buffer = vec![0u8; BUFFER_SIZE]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("Failed to read {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:016x}", hasher.digest())) +} + +/// The cached hash of `path`, or `None` when it was never hashed or the file +/// changed since (a replaced Mod archive changes size and modification time). +pub fn cached_hash(path: &Path, size: u64, modified_at: u64) -> Option { + let key = cache_key(path); + let cache = HASH_CACHE.lock(); + let entry = cache.get(&key)?; + (entry.size == size && entry.modified_at == modified_at).then(|| entry.hash.clone()) +} + +/// The cached hash of `path`, hashing and caching it when needed. +pub fn ensure_hash(path: &Path, size: u64, modified_at: u64) -> anyhow::Result { + if let Some(hash) = cached_hash(path, size, modified_at) { + return Ok(hash); + } + let hash = hash_file(path)?; + HASH_CACHE.lock().insert( + cache_key(path), + CachedHash { + size, + modified_at, + hash: hash.clone(), + }, + ); + Ok(hash) +} + +/// Mod archives are addressed by path, but the same file can be reached +/// through a symlinked `Mods` folder, so cache by the resolved path. +fn cache_key(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("celemod-{name}-{}-{unique}", std::process::id())); + fs::create_dir_all(&path).unwrap(); + path + } + + /// Reference values of the xxHash64 specification, so the implementation + /// stays compatible with Everest's. + #[test] + fn hashes_known_content() { + let root = test_dir("archive-hash"); + for (contents, expected) in [ + (&b""[..], "ef46db3751d8e999"), + (&b"abc"[..], "44bc2cf5ad770999"), + (&b"xxhash"[..], "32dd38952c4bc720"), + ] { + let path = root.join("archive.zip"); + fs::write(&path, contents).unwrap(); + assert_eq!(hash_file(&path).unwrap(), expected); + } + } + + #[test] + fn reuses_the_cached_hash_until_the_file_changes() { + let root = test_dir("archive-hash-cache"); + let path = root.join("archive.zip"); + fs::write(&path, b"first").unwrap(); + let metadata = fs::metadata(&path).unwrap(); + let size = metadata.len(); + let modified_at = metadata + .modified() + .unwrap() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + let first = ensure_hash(&path, size, modified_at).unwrap(); + assert_eq!( + cached_hash(&path, size, modified_at).as_deref(), + Some(first.as_str()) + ); + assert_eq!(ensure_hash(&path, size, modified_at).unwrap(), first); + + // A replaced archive keeps neither its size nor its timestamp. + fs::write(&path, b"second").unwrap(); + let metadata = fs::metadata(&path).unwrap(); + let changed = ensure_hash(&path, metadata.len(), modified_at + 1).unwrap(); + assert_ne!(changed, first); + assert_eq!(hash_file(&path).unwrap(), changed); + } +} diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index d445732..6dd4097 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -63,6 +63,8 @@ lazy_static::lazy_static! { static ref MIAONET_AUTHORIZATION: ParkingMutex> = ParkingMutex::new(None); } +#[path = "archive_hash.rs"] +mod archive_hash; #[path = "blacklist.rs"] mod blacklist; #[path = "crash_analysis.rs"] @@ -3233,6 +3235,37 @@ mod local_package_tests { assert!(is_newly_installed_mod("New.Mod", &installed_before)); } + #[test] + fn hashes_installed_mod_archives_for_the_update_check() { + let root = test_dir("mod-archive-hashes"); + let mods = root.join("Mods"); + fs::create_dir_all(&mods).unwrap(); + let archive = mods.join("HashCheck.zip"); + write_zip( + &archive, + &[("everest.yaml", b"- Name: HashCheck\n Version: 0.19.7\n")], + ); + // Unpacked Mods and non-archive files have no hash to compare. + fs::create_dir_all(mods.join("UnpackedMod")).unwrap(); + fs::write(mods.join("notes.txt"), b"not a Mod").unwrap(); + + let hashes = mod_archive_hashes_sync(mods.to_string_lossy().as_ref()); + assert_eq!(hashes.len(), 1); + let hash = hashes.get("HashCheck.zip").unwrap(); + assert_eq!(hash, &archive_hash::hash_file(&archive).unwrap()); + + // Replacing the archive changes the hash instead of reusing the cache. + write_zip( + &archive, + &[( + "everest.yaml", + b"- Name: HashCheck\n Version: 0.19.8\n Dependencies:\n - Name: EverestCore\n Version: 1.6531.0\n", + )], + ); + let replaced = mod_archive_hashes_sync(mods.to_string_lossy().as_ref()); + assert_ne!(replaced.get("HashCheck.zip"), Some(hash)); + } + #[test] fn identifies_and_installs_mod_archives() { let root = test_dir("drop-mod"); @@ -5244,7 +5277,8 @@ fn get_mod_update(name: String, on_event: Channel) { #[tauri::command] fn get_mod_latest_info(on_event: Channel) { std::thread::spawn(move || { - let values: Vec<(String, String, String, String)> = match get_mod_cached_new() { + let values: Vec<(String, String, String, String, Vec)> = match get_mod_cached_new() + { Ok(mods) => mods .iter() .map(|(name, item)| { @@ -5253,6 +5287,7 @@ fn get_mod_latest_info(on_event: Channel) { item.version.clone(), item.game_banana_file_id.to_string(), item.download_url.clone(), + item.xx_hash.clone(), ) }) .collect(), @@ -5266,6 +5301,52 @@ fn get_mod_latest_info(on_event: Channel) { }); } +/// Hashes the archives of a `Mods` folder, keyed by file name. +/// +/// The UI compares these with the catalog's `xxHash` entries to find Mods whose +/// newest release does not bump the version in `everest.yaml`. Results are +/// cached per file, so calling this repeatedly only stats the archives. +fn mod_archive_hashes_sync(mods_folder_path: &str) -> HashMap { + let mut hashes = HashMap::new(); + let Ok(entries) = fs::read_dir(mods_folder_path) else { + return hashes; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("zip")) + { + continue; + } + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + if !metadata.is_file() { + continue; + } + match archive_hash::ensure_hash(&path, metadata.len(), modified_at_millis(&metadata)) { + Ok(hash) => { + hashes.insert(entry.file_name().to_string_lossy().to_string(), hash); + } + Err(error) => crate::logging::warn(format_args!( + "[ WARNING ] Failed to hash {:?}: {error:#}", + entry.file_name() + )), + } + } + hashes +} + +#[tauri::command] +fn get_mod_archive_hashes(mods_folder_path: String, on_event: Channel) { + std::thread::spawn(move || { + let hashes = mod_archive_hashes_sync(&mods_folder_path); + let data = serde_json::to_string(&hashes).unwrap_or_else(|_| "{}".to_string()); + send_event(&on_event, vec![serde_json::json!(data)]); + }); +} + #[tauri::command] fn rm_mod(mods_folder_path: String, mod_name: String) -> Result<(), String> { rm_mod_sync(&mods_folder_path, &mod_name).map_err(|error| format!("{error:#}")) @@ -6461,6 +6542,7 @@ pub fn run() { verify_celeste_install, normalize_game_path, get_mod_latest_info, + get_mod_archive_hashes, show_log_window, write_frontend_log, set_catalog_offline, diff --git a/src-tauri/src/everest.rs b/src-tauri/src/everest.rs index 69cfa28..f209364 100644 --- a/src-tauri/src/everest.rs +++ b/src-tauri/src/everest.rs @@ -24,6 +24,8 @@ pub struct ModInfoCached { pub game_banana_id: i64, pub game_banana_file_id: i64, pub download_url: String, + /// xxHash64 of the newest file, the check Everest and Olympus use. + pub xx_hash: Vec, } static CATALOG_OFFLINE: AtomicBool = AtomicBool::new(false); @@ -119,6 +121,11 @@ fn compact_catalog(mods: &[wegfan::Mod]) -> HashMap { download_url: item.submission_file.url.clone(), name: item.name.clone(), version: item.version.clone(), + xx_hash: item + .xx_hash + .iter() + .map(|hash| hash.trim().to_ascii_lowercase()) + .collect(), }; (compact.name.clone(), compact) }) diff --git a/src/celemod-ui/package.json b/src/celemod-ui/package.json index 5432a7e..c6dff6f 100644 --- a/src/celemod-ui/package.json +++ b/src/celemod-ui/package.json @@ -16,6 +16,7 @@ "test:catalog-download": "node --import tsx --test src/catalogDownload.test.ts", "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:update-notes": "node --import tsx --test src/components/UpdateNotes.test.tsx" }, "dependencies": { diff --git a/src/celemod-ui/src/modUpdates.test.ts b/src/celemod-ui/src/modUpdates.test.ts new file mode 100644 index 0000000..9bbac6b --- /dev/null +++ b/src/celemod-ui/src/modUpdates.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + collectModUpdates, + type InstalledModFile, + type LatestModTuple, +} from "./modUpdates"; + +const installedMod = ( + overrides: Partial = {}, +): InstalledModFile => ({ + name: "SampleMod", + version: "1.0.0", + file: "SampleMod.zip", + isDirectory: false, + ...overrides, +}); + +interface LatestModOverrides { + name?: string; + version?: string; + gbFile?: string; + url?: string; + xxHash?: string[]; +} + +const latestMod = (overrides: LatestModOverrides = {}): LatestModTuple => [ + overrides.name ?? "SampleMod", + overrides.version ?? "1.0.0", + overrides.gbFile ?? "1234", + overrides.url ?? "https://example.invalid/SampleMod.zip", + overrides.xxHash ?? ["aaaaaaaaaaaaaaaa"], +]; + +test("detects updates from the version number", () => { + const updates = collectModUpdates( + [installedMod()], + {}, + [latestMod({ version: "1.1.0" })], + ); + assert.deepEqual( + updates.map((update) => [update.name, update.current, update.version]), + [["SampleMod", "1.0.0", "1.1.0"]], + ); +}); + +test("ignores Mods that are not in the catalog", () => { + assert.deepEqual(collectModUpdates([installedMod()], {}, []), []); +}); + +test("detects an update when only the archive changed", () => { + // aonHelper 0.19.8 still declares 0.19.7, so the version numbers are equal + // while the installed archive is not the newest file. + const updates = collectModUpdates( + [installedMod({ name: "aonHelper", version: "0.19.7", file: "aonHelper.zip" })], + { "aonHelper.zip": "749a898300d45f58" }, + [ + latestMod({ name: "aonHelper", version: "0.19.7", xxHash: ["1111111111111111"] }), + ], + ); + assert.equal(updates.length, 1); + assert.equal(updates[0].version, "0.19.7"); + assert.equal(updates[0].current, "0.19.7"); +}); + +test("keeps Mods whose archive matches the newest file", () => { + const updates = collectModUpdates( + [installedMod({ name: "aonHelper", version: "0.19.7", file: "aonHelper.zip" })], + { "aonHelper.zip": "749a898300d45f58" }, + [ + latestMod({ name: "aonHelper", version: "0.19.7", xxHash: ["749a898300d45f58"] }), + ], + ); + assert.deepEqual(updates, []); +}); + +test("cannot compare an unpacked Mod by hash", () => { + const updates = collectModUpdates( + [ + installedMod({ + name: "aonHelper", + version: "0.19.7", + file: "aonHelper", + isDirectory: true, + }), + ], + {}, + [latestMod({ name: "aonHelper", version: "0.19.7", xxHash: ["749a898300d45f58"] })], + ); + assert.deepEqual(updates, []); +}); + +test("keeps the version check when the catalog has no hash", () => { + const updates = collectModUpdates( + [installedMod({ version: "1.0.0" })], + { "SampleMod.zip": "ffffffffffffffff" }, + [latestMod({ xxHash: [] })], + ); + assert.deepEqual(updates, []); +}); diff --git a/src/celemod-ui/src/modUpdates.ts b/src/celemod-ui/src/modUpdates.ts new file mode 100644 index 0000000..1b3f847 --- /dev/null +++ b/src/celemod-ui/src/modUpdates.ts @@ -0,0 +1,65 @@ +import { compareVersion } from "./utils"; + +/** + * A row of `get_mod_latest_info`: the newest file the catalog knows for a Mod. + * `xxHash` holds the xxHash64 of that file, the same value Everest and Olympus + * compare installed archives against. + */ +export type LatestModTuple = [ + name: string, + version: string, + gbFile: string, + url: string, + xxHash: string[], +]; + +export interface LatestModInfo { + name: string; + version: string; + gbFile: string; + current: string; + url: string; + xxHash: string[]; +} + +export interface InstalledModFile { + name: string; + version: string; + file: string; + isDirectory: boolean; +} + +/** + * Mods whose newest release can be installed. + * + * The catalog version is used first, but upstream archives sometimes keep an + * old or wrong `everest.yaml` version (aonHelper 0.19.8 still declares 0.19.7), + * which hides the update from a version comparison. Everest and Olympus + * therefore check the archive hash instead, and CeleMod does the same whenever + * both the installed archive and the catalog provide one. + */ +export const collectModUpdates = ( + installed: readonly InstalledModFile[], + hashByFile: Readonly>, + latestRaw: readonly LatestModTuple[], +): LatestModInfo[] => { + const latestByName = new Map( + latestRaw.map((entry) => [entry[0], entry] as const), + ); + return installed.flatMap((mod) => { + const latest = latestByName.get(mod.name); + if (!latest) return []; + const [, version, gbFile, url, xxHash] = latest; + const hash = hashByFile[mod.file]; + const newerVersion = compareVersion(version, mod.version) > 0; + const replacedArchive = + !mod.isDirectory && + Boolean(hash) && + xxHash.length > 0 && + !xxHash.includes(hash); + if (!newerVersion && !replacedArchive) return []; + return [ + { name: mod.name, version, gbFile, current: mod.version, url, xxHash }, + ]; + }); +}; diff --git a/src/celemod-ui/src/routes/Manage.tsx b/src/celemod-ui/src/routes/Manage.tsx index aa46be6..469e99f 100644 --- a/src/celemod-ui/src/routes/Manage.tsx +++ b/src/celemod-ui/src/routes/Manage.tsx @@ -12,6 +12,7 @@ import { import "./Manage.scss"; import { MOD_TYPE_OPTIONS, + currentMirror, reloadBlacklistState, reloadInstalledMods, useAlwaysOnMods, @@ -38,6 +39,11 @@ import { CatalogMod, loadModCatalog } from "../api/modCatalog"; import { Content, searchSubmission } from "../api/wegfan"; import { sanitizeDescriptionHtml } from "../sanitizeDescriptionHtml"; import { getAvailableModPageUrl, getOtherModPageSource } from "../modPage"; +import { + collectModUpdates, + type LatestModInfo, + type LatestModTuple, +} from "../modUpdates"; import { ManageCatalogMeta, ManageNode, @@ -424,14 +430,6 @@ const ProfileExportPopup = ({ ); }; -type LatestModInfo = { - name: string; - version: string; - gbFile: string; - current: string; - url: string; -}; - type FullModCheckProgress = { current: number; total: number; @@ -1118,9 +1116,10 @@ export const Manage = () => { const downloadMod = useDownloadStore((state) => state.downloadMod); const downloadMods = useDownloadStore((state) => state.downloadMods); const [catalog, setCatalog] = useState([]); - const [latestRaw, setLatestRaw] = useState< - [string, string, string, string][] - >([]); + const [latestRaw, setLatestRaw] = useState([]); + const [archiveHashes, setArchiveHashes] = useState>( + {}, + ); const [updateStates, setUpdateStates] = useState>({}); const [batchUpdateProgress, setBatchUpdateProgress] = useState(null); @@ -1211,6 +1210,24 @@ export const Manage = () => { ); }, [cacheTtl]); + /* Archive hashes decide whether an installed Mod still matches the newest + released file, the way Everest and Olympus check for updates. They are + only needed here and are cached per file by the backend, so the Mod list + itself keeps loading without reading every archive. */ + useEffect(() => { + if (!gamePath) { + setArchiveHashes({}); + return; + } + let canceled = false; + callRemote("get_mod_archive_hashes", `${gamePath}/Mods`, (data: string) => { + if (!canceled) setArchiveHashes(JSON.parse(data)); + }).catch((error) => console.error("Failed to hash installed Mods", error)); + return () => { + canceled = true; + }; + }, [gamePath, installedMods]); + useEffect(() => { if (!gamePath || !profileEnabled) { setOlympusProfiles([]); @@ -1306,20 +1323,17 @@ export const Manage = () => { const updates = useMemo( () => - Object.values(nodes).flatMap((node) => { - const latest = latestRaw.find((item) => item[0] === node.name); - if (!latest || compareVersion(latest[1], node.version) <= 0) return []; - return [ - { - name: node.name, - version: latest[1], - gbFile: latest[2], - current: node.version, - url: latest[3], - }, - ]; - }), - [nodes, latestRaw], + collectModUpdates( + Object.values(nodes).map((node) => ({ + name: node.name, + version: node.version, + file: node.file, + isDirectory: node.isDirectory, + })), + archiveHashes, + latestRaw, + ), + [nodes, archiveHashes, latestRaw], ); const updateNames = useMemo( () => new Set(updates.map((update) => update.name)), @@ -1328,7 +1342,13 @@ export const Manage = () => { const updateVersions = useMemo( () => Object.fromEntries( - updates.map((update) => [update.name, update.version]), + updates.map((update) => [ + update.name, + // A release that keeps the old version number only differs by archive. + compareVersion(update.version, update.current) > 0 + ? update.version + : _i18n.t("最新版"), + ]), ), [updates], ); @@ -1634,41 +1654,91 @@ export const Manage = () => { [downloadMods], ); - const updateNode = useCallback( - (name: string): Promise => { - const update = updates.find((item) => item.name === name); - if (!update || activeUpdates.current.has(name)) - return Promise.resolve(false); - activeUpdates.current.add(name); - setUpdateStates((state) => ({ ...state, [name]: _i18n.t("更新中…") })); - return new Promise((resolve) => { - downloadMod(name, update.gbFile === "-1" ? update.url : update.gbFile, { + /* A mirror may serve an archive the catalog does not know (for example an + old build stored under the new file id), which would leave the Mod + outdated while looking updated. Updates are therefore verified against + the catalog hash the way Everest does it, and another source is tried + when the download does not match. */ + const updateSources = useCallback((update: LatestModInfo): string[] => { + if (update.gbFile === "-1") return update.url ? [update.url] : []; + const urlFor = (mirror: string) => + mirror === "wegfan" + ? `https://celeste.weg.fan/api/v2/download/gamebanana-files/${update.gbFile}` + : mirror === "0x0ade" + ? `https://celestemodupdater.0x0a.de/banana-mirror/${update.gbFile}.zip` + : `https://gamebanana.com/dl/${update.gbFile}`; + return [...new Set([currentMirror(), "0x0ade", "gamebanana"].map(urlFor))]; + }, []); + + const downloadUpdate = useCallback( + (name: string, source: string): Promise => + new Promise((resolve) => { + downloadMod(name, source, { force: true, onProgress: (_task, progress) => setUpdateStates((state) => ({ ...state, [name]: `${progress.toFixed(0)}%`, })), - onFinished: () => { - activeUpdates.current.delete(name); - setUpdateStates((state) => ({ - ...state, - [name]: _i18n.t("已更新"), - })); - resolve(true); - }, - onFailed: () => { - activeUpdates.current.delete(name); - setUpdateStates((state) => ({ - ...state, - [name]: _i18n.t("更新失败"), - })); - resolve(false); - }, + onFinished: () => resolve(true), + onFailed: () => resolve(false), }); - }); + }), + [downloadMod], + ); + + const installedArchiveMatches = useCallback( + async (name: string, update: LatestModInfo) => { + if (update.xxHash.length === 0) return true; + const hashes = await callRemote>( + "get_mod_archive_hashes", + `${gamePath}/Mods`, + ); + const installed = (await reloadInstalledMods(gamePath)).find( + (mod) => mod.name === name, + ); + const hash = installed ? hashes[installed.file] : undefined; + // Unpacked Mods cannot be compared, and neither can a failed hash. + return !hash || update.xxHash.includes(hash); + }, + [gamePath], + ); + + const updateNode = useCallback( + (name: string): Promise => { + const update = updates.find((item) => item.name === name); + if (!update || activeUpdates.current.has(name)) + return Promise.resolve(false); + activeUpdates.current.add(name); + setUpdateStates((state) => ({ ...state, [name]: _i18n.t("更新中…") })); + const attempt = async () => { + for (const source of updateSources(update)) { + if (!(await downloadUpdate(name, source))) continue; + if (await installedArchiveMatches(name, update)) return true; + } + return false; + }; + return attempt().then( + (updated) => { + activeUpdates.current.delete(name); + setUpdateStates((state) => ({ + ...state, + [name]: _i18n.t(updated ? "已更新" : "更新失败"), + })); + return updated; + }, + (error) => { + console.error("Failed to update Mod", name, error); + activeUpdates.current.delete(name); + setUpdateStates((state) => ({ + ...state, + [name]: _i18n.t("更新失败"), + })); + return false; + }, + ); }, - [downloadMod, updates], + [downloadUpdate, installedArchiveMatches, updateSources, updates], ); const updateAll = useCallback(() => {