Skip to main content

hexomc_lib/mods/
updater.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use sha1::{Digest, Sha1};
5
6use crate::{
7    download::{download_file, DownloadTask},
8    error::{HexoError, Result},
9    mods::{
10        curseforge::{CfModFile, CurseForgeClient},
11        detector::{detect_mods, ModInfo, ModLoader},
12        modrinth::{ModrinthClient, MrVersion},
13    },
14};
15
16/// Where an available update came from.
17#[derive(Debug, Clone)]
18pub enum UpdateSource {
19    Modrinth(MrVersion),
20    CurseForge { file: CfModFile, project_id: u64 },
21}
22
23#[derive(Debug, Clone)]
24pub struct ModUpdate {
25    pub current: ModInfo,
26    pub source: UpdateSource,
27}
28
29impl ModUpdate {
30    pub fn version_name(&self) -> &str {
31        match &self.source {
32            UpdateSource::Modrinth(v) => &v.version_number,
33            UpdateSource::CurseForge { file, .. } => &file.display_name,
34        }
35    }
36
37    pub fn file_name(&self) -> Option<&str> {
38        match &self.source {
39            UpdateSource::Modrinth(v) => v.file_name(),
40            UpdateSource::CurseForge { file, .. } => Some(&file.file_name),
41        }
42    }
43
44    pub fn download_url(&self) -> Option<&str> {
45        match &self.source {
46            UpdateSource::Modrinth(v) => v.download_url(),
47            UpdateSource::CurseForge { file, .. } => file.download_url.as_deref(),
48        }
49    }
50
51    pub fn sha1(&self) -> Option<&str> {
52        match &self.source {
53            UpdateSource::Modrinth(v) => v.sha1(),
54            UpdateSource::CurseForge { file, .. } => file.sha1(),
55        }
56    }
57}
58
59/// Scan a mods directory and check for updates, preferring Modrinth.
60///
61/// Modrinth is queried first by file hash, so no ID map is needed for mods it
62/// knows. `mod_id_map` (mod_id -> CurseForge project ID) and `curseforge` are
63/// only a fallback for mods Modrinth doesn't recognise; pass `None` and an empty
64/// map to use Modrinth exclusively.
65pub async fn check_updates(
66    mods_dir: &Path,
67    mc_version: &str,
68    loader: &ModLoader,
69    modrinth: &ModrinthClient,
70    curseforge: Option<&CurseForgeClient>,
71    mod_id_map: &HashMap<String, u64>,
72) -> Result<Vec<ModUpdate>> {
73    let mods = detect_mods(mods_dir);
74    let mut updates = Vec::new();
75
76    for mod_info in mods {
77        let sha1 = file_sha1(&mod_info.file_path);
78
79        if let Some(hash) = &sha1 {
80            if let Some(latest) = modrinth
81                .get_latest_by_hash(hash, mc_version, loader)
82                .await?
83            {
84                // A different primary-file hash means a newer file.
85                if latest.sha1() != Some(hash.as_str()) {
86                    updates.push(ModUpdate {
87                        current: mod_info,
88                        source: UpdateSource::Modrinth(latest),
89                    });
90                }
91                continue;
92            }
93        }
94
95        let (Some(cf), Some(&cf_id)) = (curseforge, mod_id_map.get(&mod_info.mod_id)) else {
96            continue;
97        };
98
99        if let Some(latest_file) = cf.get_latest_file(cf_id, mc_version, loader).await? {
100            if latest_file.display_name != mod_info.version
101                && latest_file.file_name != mod_info.file_name
102            {
103                updates.push(ModUpdate {
104                    current: mod_info,
105                    source: UpdateSource::CurseForge {
106                        file: latest_file,
107                        project_id: cf_id,
108                    },
109                });
110            }
111        }
112    }
113
114    Ok(updates)
115}
116
117/// Download the update and replace the old mod file.
118pub async fn update_mod(update: &ModUpdate, mods_dir: &Path) -> Result<()> {
119    let url = update
120        .download_url()
121        .ok_or_else(|| HexoError::DownloadFailed {
122            url: format!("mod {} has no download URL", update.current.name),
123        })?;
124    let file_name = update
125        .file_name()
126        .ok_or_else(|| HexoError::DownloadFailed {
127            url: format!("mod {} has no file name", update.current.name),
128        })?;
129
130    let new_path = mods_dir.join(file_name);
131
132    let task = match update.sha1() {
133        Some(sha1) if !sha1.is_empty() => {
134            DownloadTask::new(url, &new_path).with_sha1(sha1.to_string())
135        }
136        _ => DownloadTask::new(url, &new_path),
137    };
138
139    download_file(&task).await?;
140
141    if update.current.file_path != new_path && update.current.file_path.exists() {
142        tokio::fs::remove_file(&update.current.file_path).await?;
143    }
144
145    Ok(())
146}
147
148fn file_sha1(path: &Path) -> Option<String> {
149    let data = std::fs::read(path).ok()?;
150    let mut hasher = Sha1::new();
151    hasher.update(&data);
152    Some(hex::encode(hasher.finalize()))
153}