From f968b2d3704c5392e5c0b80d466bf452812f5da5 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:54:34 +0800 Subject: [PATCH] fix(mods): check Mod updates by archive hash like Olympus CeleMod decided whether an installed Mod was up to date by comparing the version in its `everest.yaml` with the version the catalog lists. Upstream archives do not always keep that number in sync: the aonHelper 0.19.8 file still declares `Version: 0.19.7` (a typo, GameBanana calls the file 0.19.8), so players whose installed archive declares 0.19.7 never see the update, while Olympus offers it. Olympus and Everest ignore the version for this check: they compare the xxHash64 of the installed archive with the hashes of the newest file in the update database. Do the same: * `archive_hash` hashes installed archives with xxHash64 (matching `Everest.GetChecksum`) and caches the result per file, so the Mod list keeps loading without reading every archive. * `get_mod_latest_info` now returns the catalog `xxHash` entries and `get_mod_archive_hashes` returns the installed archive hashes. * `collectModUpdates` reports an update when the version moved, or when the installed archive is not one of the newest file's hashes. Unpacked Mods and entries without a hash keep the version comparison. The update itself is also verified against the catalog hash now, and the next source (0x0ade mirror, then GameBanana) is tried when the archive does not match. The Chinese mirror serves an old LoennProjectManager archive under the newest file id, so every update installed the same v0.4.5 build again and the update badge never went away. --- Cargo.lock | 7 + src-tauri/Cargo.toml | 1 + src-tauri/src/archive_hash.rs | 149 ++++++++++++++++++++++ src-tauri/src/backend.rs | 84 +++++++++++- src-tauri/src/everest.rs | 7 + src/celemod-ui/package.json | 1 + src/celemod-ui/src/modUpdates.test.ts | 100 +++++++++++++++ src/celemod-ui/src/modUpdates.ts | 65 ++++++++++ src/celemod-ui/src/routes/Manage.tsx | 176 ++++++++++++++++++-------- 9 files changed, 536 insertions(+), 54 deletions(-) create mode 100644 src-tauri/src/archive_hash.rs create mode 100644 src/celemod-ui/src/modUpdates.test.ts create mode 100644 src/celemod-ui/src/modUpdates.ts 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(() => {