Skip to main content

hexomc_lib/install/
vanilla.rs

1use std::path::{Path, PathBuf};
2use tokio::fs;
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    download::{download_batch, download_file, DownloadTask},
7    error::{HexoError, Result},
8    version::manifest::{
9        fetch_asset_index, fetch_version_manifest, fetch_version_json,
10        check_library_rule, check_jvm_rule,
11        Argument, ArgumentValue, VersionJson,
12    },
13};
14
15#[derive(Debug, Deserialize, Serialize, Clone)]
16pub struct LibEntry {
17    pub name: String,
18    pub path: PathBuf,
19    pub sha1: String,
20}
21
22#[derive(Debug, Deserialize, Serialize, Clone)]
23pub struct NativeEntry {
24    pub name: String,
25    pub path: PathBuf,
26    pub sha1: String,
27}
28
29#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
30#[serde(rename_all = "lowercase")]
31pub enum LoaderType {
32    Vanilla,
33    Fabric,
34    Forge,
35    NeoForge,
36}
37
38#[derive(Debug, Deserialize, Serialize, Clone)]
39pub struct InstanceConfig {
40    pub version_id: String,
41    pub loader_type: LoaderType,
42    pub main_class: String,
43    /// JVM + game launch args (with `${placeholder}` tokens).
44    pub start_args: Vec<String>,
45    pub lib_list: Vec<LibEntry>,
46    pub natives: Vec<NativeEntry>,
47    pub assets_id: String,
48    pub java_version: u32,
49}
50
51impl InstanceConfig {
52    pub fn save_path(instance_dir: &Path) -> PathBuf {
53        instance_dir.join("instance_config.json")
54    }
55
56    pub async fn save(&self, instance_dir: &Path) -> Result<()> {
57        let json = serde_json::to_string_pretty(self)?;
58        fs::write(Self::save_path(instance_dir), json).await?;
59        Ok(())
60    }
61
62    pub async fn load(instance_dir: &Path) -> Result<Self> {
63        let data = fs::read_to_string(Self::save_path(instance_dir)).await?;
64        let config: Self = serde_json::from_str(&data)?;
65        Ok(config)
66    }
67}
68
69/// Install vanilla Minecraft.
70///
71/// `version_id`: e.g. `"1.21.4"`
72/// `instance_name`: instance directory name (chosen by the caller to avoid collisions)
73/// `base_dir`: launcher root (holds assets/ libraries/ instance/)
74/// `progress`: callback (done, total, description)
75pub async fn install_vanilla<F>(
76    version_id: &str,
77    instance_name: &str,
78    base_dir: &Path,
79    progress: F,
80) -> Result<InstanceConfig>
81where
82    F: Fn(usize, usize, &str) + Send + Sync + Clone + 'static,
83{
84    let manifest = fetch_version_manifest().await?;
85    let entry = manifest
86        .versions
87        .iter()
88        .find(|v| v.id == version_id)
89        .ok_or_else(|| HexoError::VersionNotFound(version_id.to_string()))?;
90
91    progress(0, 4, "取得版本資訊");
92    let version_json = fetch_version_json(&entry.url).await?;
93
94    let instance_dir = base_dir.join("instance").join(instance_name);
95    let lib_dir = base_dir.join("libraries");
96    let assets_dir = base_dir.join("assets");
97
98    init_instance_dirs(&instance_dir, &assets_dir, &lib_dir).await?;
99
100    progress(1, 4, "下載 libraries");
101    let (lib_list, natives) = download_libraries(&version_json, &lib_dir, progress.clone()).await?;
102
103    progress(2, 4, "下載 assets");
104    download_assets(&version_json, &assets_dir, progress.clone()).await?;
105
106    progress(3, 4, "下載 client jar");
107    let client_jar = instance_dir.join(format!("{}.jar", version_id));
108    download_file(
109        &DownloadTask::new(
110            version_json.downloads.client.url.clone(),
111            &client_jar,
112        )
113        .with_sha1(version_json.downloads.client.sha1.clone()),
114    )
115    .await?;
116
117    progress(4, 4, "解析啟動參數");
118    let start_args = parse_start_args(&version_json);
119
120    let config = InstanceConfig {
121        version_id: version_id.to_string(),
122        loader_type: LoaderType::Vanilla,
123        main_class: version_json.main_class.clone(),
124        start_args,
125        lib_list,
126        natives,
127        assets_id: version_json.asset_index.id.clone(),
128        java_version: version_json.java_version.major_version,
129    };
130
131    config.save(&instance_dir).await?;
132
133    Ok(config)
134}
135
136async fn init_instance_dirs(
137    instance_dir: &Path,
138    assets_dir: &Path,
139    lib_dir: &Path,
140) -> Result<()> {
141    let minecraft_dir = instance_dir.join(".minecraft");
142    for dir in &[
143        instance_dir.to_path_buf(),
144        minecraft_dir.join("mods"),
145        minecraft_dir.join("resourcepacks"),
146        minecraft_dir.join("saves"),
147        minecraft_dir.join("screenshots"),
148        minecraft_dir.join("shaderpacks"),
149        minecraft_dir.join("logs"),
150        assets_dir.join("objects"),
151        assets_dir.join("indexes"),
152        lib_dir.to_path_buf(),
153    ] {
154        fs::create_dir_all(dir).await?;
155    }
156    Ok(())
157}
158
159async fn download_libraries(
160    version_json: &VersionJson,
161    lib_dir: &Path,
162    progress: impl Fn(usize, usize, &str) + Send + Sync + Clone + 'static,
163) -> Result<(Vec<LibEntry>, Vec<NativeEntry>)> {
164    let mut lib_tasks: Vec<(DownloadTask, LibEntry)> = Vec::new();
165    let mut native_tasks: Vec<(DownloadTask, NativeEntry)> = Vec::new();
166
167    for lib in &version_json.libraries {
168        if let Some(rules) = &lib.rules {
169            if !check_library_rule(rules) {
170                continue;
171            }
172        }
173
174        if let Some(downloads) = &lib.downloads {
175            if let Some(artifact) = &downloads.artifact {
176                let path = lib_dir.join(&artifact.path);
177                lib_tasks.push((
178                    DownloadTask::new(&artifact.url, &path)
179                        .with_sha1(artifact.sha1.clone()),
180                    LibEntry {
181                        name: lib.name.clone(),
182                        path: path.clone(),
183                        sha1: artifact.sha1.clone(),
184                    },
185                ));
186            }
187
188            if let Some(natives_map) = &lib.natives {
189                let native_key = native_key_for_platform();
190                if let Some(classifier_key) = natives_map.get(native_key) {
191                    if let Some(classifiers) = &downloads.classifiers {
192                        if let Some(native_artifact) = classifiers.get(classifier_key) {
193                            let path = lib_dir.join(&native_artifact.path);
194                            native_tasks.push((
195                                DownloadTask::new(&native_artifact.url, &path)
196                                    .with_sha1(native_artifact.sha1.clone()),
197                                NativeEntry {
198                                    name: lib.name.clone(),
199                                    path: path.clone(),
200                                    sha1: native_artifact.sha1.clone(),
201                                },
202                            ));
203                        }
204                    }
205                }
206            }
207        }
208    }
209
210    let total = lib_tasks.len() + native_tasks.len();
211    let p = progress.clone();
212
213    let lib_download_tasks: Vec<DownloadTask> =
214        lib_tasks.iter().map(|(t, _)| t.clone()).collect();
215    let native_download_tasks: Vec<DownloadTask> =
216        native_tasks.iter().map(|(t, _)| t.clone()).collect();
217
218    download_batch(lib_download_tasks, 16, move |done, _| {
219        p(done, total, "下載 libraries");
220    })
221    .await?;
222
223    download_batch(native_download_tasks, 8, move |done, _| {
224        progress(done, total, "下載 native libraries");
225    })
226    .await?;
227
228    Ok((
229        lib_tasks.into_iter().map(|(_, e)| e).collect(),
230        native_tasks.into_iter().map(|(_, e)| e).collect(),
231    ))
232}
233
234async fn download_assets(
235    version_json: &VersionJson,
236    assets_dir: &Path,
237    progress: impl Fn(usize, usize, &str) + Send + Sync + Clone + 'static,
238) -> Result<()> {
239    let index = &version_json.asset_index;
240
241    let index_path = assets_dir
242        .join("indexes")
243        .join(format!("{}.json", index.id));
244
245    let asset_data = fetch_asset_index(&index.url).await?;
246    let json = serde_json::to_string_pretty(&asset_data)?;
247    fs::write(&index_path, json).await?;
248
249    let tasks: Vec<DownloadTask> = asset_data
250        .objects
251        .values()
252        .map(|obj| DownloadTask::asset(&obj.hash, assets_dir))
253        .collect();
254
255    let total = tasks.len();
256    download_batch(tasks, 32, move |done, _| {
257        progress(done, total, "下載 assets");
258    })
259    .await
260}
261
262/// Parse JVM + game args, expanding conditional args by rule.
263pub fn parse_start_args(version_json: &VersionJson) -> Vec<String> {
264    let mut args: Vec<String> = Vec::new();
265
266    if let Some(arguments) = &version_json.arguments {
267        for arg in &arguments.jvm {
268            collect_arg(arg, &mut args, true);
269        }
270        // Main class goes between JVM args and game args.
271        args.push("${mainClass}".to_string());
272        for arg in &arguments.game {
273            collect_arg(arg, &mut args, false);
274        }
275    } else if let Some(mc_args) = &version_json.minecraft_arguments {
276        // Old format: a single whitespace-separated string.
277        args.extend(mc_args.split_whitespace().map(|s| s.to_string()));
278    }
279
280    args
281}
282
283fn collect_arg(arg: &Argument, out: &mut Vec<String>, is_jvm: bool) {
284    match arg {
285        Argument::Simple(s) => out.push(s.clone()),
286        Argument::Conditional { rules, value } => {
287            let allowed = if is_jvm {
288                check_jvm_rule(rules)
289            } else {
290                check_library_rule(rules)
291            };
292            if allowed {
293                match value {
294                    ArgumentValue::Single(s) => out.push(s.clone()),
295                    ArgumentValue::Multiple(vs) => out.extend(vs.iter().cloned()),
296                }
297            }
298        }
299    }
300}
301
302fn native_key_for_platform() -> &'static str {
303    if cfg!(target_os = "windows") {
304        "windows"
305    } else if cfg!(target_os = "macos") {
306        "osx"
307    } else {
308        "linux"
309    }
310}