>(), false, None)
198 | .await?)
199 | }
200 |
201 | pub async fn resolve_unordered_and_integrate_with_provider_init(
202 | game_path: P,
203 | state: &mut State,
204 | mod_specs: &[ModSpecification],
205 | update: bool,
206 | init: F,
207 | ) -> Result<(), MintError>
208 | where
209 | P: AsRef,
210 | F: Fn(&mut State, String, &ProviderFactory) -> Result<(), MintError>,
211 | {
212 | loop {
213 | match resolve_unordered_and_integrate(&game_path, state, mod_specs, update).await {
214 | Ok(()) => return Ok(()),
215 | Err(ref e)
216 | if let IntegrationError::ProviderError { ref source } = e
217 | && let ProviderError::NoProvider { ref url, factory } = source =>
218 | {
219 | init(state, url.clone(), factory)?
220 | }
221 | Err(e) => Err(e)?,
222 | }
223 | }
224 | }
225 |
226 | #[allow(clippy::needless_pass_by_ref_mut)]
227 | pub async fn resolve_ordered_with_provider_init(
228 | state: &mut State,
229 | mod_specs: &[ModSpecification],
230 | init: F,
231 | ) -> Result, MintError>
232 | where
233 | F: Fn(&mut State, String, &ProviderFactory) -> Result<(), MintError>,
234 | {
235 | loop {
236 | match resolve_ordered(state, mod_specs).await {
237 | Ok(mod_paths) => return Ok(mod_paths),
238 | Err(ref e)
239 | if let MintError::IntegrationError { ref source } = e
240 | && let IntegrationError::ProviderError { ref source } = source
241 | && let ProviderError::NoProvider { ref url, factory } = source =>
242 | {
243 | init(state, url.clone(), factory)?
244 | }
245 | Err(e) => Err(e)?,
246 | }
247 | }
248 | }
249 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | use std::collections::BTreeSet;
2 | use std::path::PathBuf;
3 |
4 | use anyhow::{anyhow, Context, Result};
5 | use clap::{Parser, Subcommand};
6 | use tracing::{debug, info};
7 |
8 | use mint::mod_lints::{run_lints, LintId};
9 | use mint::providers::ProviderFactory;
10 | use mint::{gui::gui, providers::ModSpecification, state::State};
11 | use mint::{
12 | resolve_ordered_with_provider_init, resolve_unordered_and_integrate_with_provider_init, Dirs,
13 | MintError,
14 | };
15 |
16 | /// Command line integration tool.
17 | #[derive(Parser, Debug)]
18 | struct ActionIntegrate {
19 | /// Path to FSD-WindowsNoEditor.pak (FSD-WinGDK.pak for Microsoft Store version) located
20 | /// inside the "Deep Rock Galactic" installation directory under FSD/Content/Paks. Only
21 | /// necessary if it cannot be found automatically.
22 | #[arg(short, long)]
23 | fsd_pak: Option,
24 |
25 | /// Update mods. By default all mods and metadata are cached offline so this is necessary to
26 | /// check for updates.
27 | #[arg(short, long)]
28 | update: bool,
29 |
30 | /// Paths of mods to integrate
31 | ///
32 | /// Can be a file path or URL to a .pak or .zip file or a URL to a mod on https://mod.io/g/drg
33 | /// Examples:
34 | /// ./local/path/test-mod.pak
35 | /// https://mod.io/g/drg/m/custom-difficulty
36 | /// https://example.org/some-online-mod-repository/public-mod.zip
37 | #[arg(short, long, num_args=0.., verbatim_doc_comment)]
38 | mods: Vec,
39 | }
40 |
41 | /// Integrate a profile
42 | #[derive(Parser, Debug)]
43 | struct ActionIntegrateProfile {
44 | /// Path to FSD-WindowsNoEditor.pak (FSD-WinGDK.pak for Microsoft Store version) located
45 | /// inside the "Deep Rock Galactic" installation directory under FSD/Content/Paks. Only
46 | /// necessary if it cannot be found automatically.
47 | #[arg(short, long)]
48 | fsd_pak: Option,
49 |
50 | /// Update mods. By default all mods and metadata are cached offline so this is necessary to
51 | /// check for updates.
52 | #[arg(short, long)]
53 | update: bool,
54 |
55 | /// Profile to integrate.
56 | profile: String,
57 | }
58 |
59 | /// Launch via steam
60 | #[derive(Parser, Debug)]
61 | struct ActionLaunch {
62 | args: Vec,
63 | }
64 |
65 | /// Lint the mod bundle that would be created for a profile.
66 | #[derive(Parser, Debug)]
67 | struct ActionLint {
68 | /// Path to FSD-WindowsNoEditor.pak (FSD-WinGDK.pak for Microsoft Store version) located
69 | /// inside the "Deep Rock Galactic" installation directory under FSD/Content/Paks. Only
70 | /// necessary if it cannot be found automatically.
71 | #[arg(short, long)]
72 | fsd_pak: Option,
73 |
74 | /// Profile to lint.
75 | profile: String,
76 | }
77 |
78 | #[derive(Subcommand, Debug)]
79 | enum Action {
80 | Integrate(ActionIntegrate),
81 | Profile(ActionIntegrateProfile),
82 | Launch(ActionLaunch),
83 | Lint(ActionLint),
84 | }
85 |
86 | #[derive(Parser, Debug)]
87 | #[command(author, version=mint_lib::built_info::GIT_VERSION.unwrap())]
88 | struct Args {
89 | #[command(subcommand)]
90 | action: Option,
91 |
92 | /// Location to store configs and data
93 | #[arg(long)]
94 | appdata: Option,
95 | }
96 |
97 | fn main() -> Result<()> {
98 | #[cfg(target_os = "windows")]
99 | {
100 | // Try to enable ANSI code support on Windows 10 for console. If it fails, then whatever
101 | // *shrugs*.
102 | let _res = ansi_term::enable_ansi_support();
103 | }
104 |
105 | let args = Args::parse();
106 |
107 | let dirs = args
108 | .appdata
109 | .as_ref()
110 | .map(Dirs::from_path)
111 | .unwrap_or_else(Dirs::default_xdg)?;
112 |
113 | std::env::set_var("RUST_BACKTRACE", "1");
114 |
115 | let _guard = mint_lib::setup_logging(dirs.data_dir.join("mint.log"), "mint")?;
116 | debug!("logging setup complete");
117 |
118 | info!("config dir = {}", dirs.config_dir.display());
119 | info!("cache dir = {}", dirs.cache_dir.display());
120 | info!("data dir = {}", dirs.data_dir.display());
121 |
122 | let rt = tokio::runtime::Runtime::new().expect("Unable to create Runtime");
123 | debug!("tokio runtime created");
124 | let _enter = rt.enter();
125 |
126 | debug!(?args);
127 |
128 | match args.action {
129 | Some(Action::Integrate(action)) => rt.block_on(async {
130 | action_integrate(dirs, action).await?;
131 | Ok(())
132 | }),
133 | Some(Action::Profile(action)) => rt.block_on(async {
134 | action_integrate_profile(dirs, action).await?;
135 | Ok(())
136 | }),
137 | Some(Action::Launch(action)) => {
138 | std::thread::spawn(move || {
139 | rt.block_on(std::future::pending::<()>());
140 | });
141 | gui(dirs, Some(action.args))?;
142 | Ok(())
143 | }
144 | Some(Action::Lint(action)) => rt.block_on(async {
145 | action_lint(dirs, action).await?;
146 | Ok(())
147 | }),
148 | None => {
149 | std::thread::spawn(move || {
150 | rt.block_on(std::future::pending::<()>());
151 | });
152 | gui(dirs, None)?;
153 | Ok(())
154 | }
155 | }
156 | }
157 |
158 | #[tracing::instrument(skip(state))]
159 | fn init_provider(
160 | state: &mut State,
161 | url: String,
162 | factory: &ProviderFactory,
163 | ) -> Result<(), MintError> {
164 | info!("initializing provider for {:?}", url);
165 |
166 | let params = state
167 | .config
168 | .provider_parameters
169 | .entry(factory.id.to_owned())
170 | .or_default();
171 | for p in factory.parameters {
172 | if !params.contains_key(p.name) {
173 | // this blocks but since we're calling it on the main thread it'll be fine
174 | let value =
175 | dialoguer::Password::with_theme(&dialoguer::theme::ColorfulTheme::default())
176 | .with_prompt(p.description)
177 | .interact()
178 | .unwrap();
179 | params.insert(p.id.to_owned(), value);
180 | }
181 | }
182 | Ok(state.store.add_provider(factory, params)?)
183 | }
184 |
185 | fn get_pak_path(state: &State, arg: &Option) -> Result {
186 | arg.as_ref()
187 | .or_else(|| state.config.drg_pak_path.as_ref())
188 | .cloned()
189 | .context("Could not find DRG pak file, please specify manually with the --fsd_pak flag")
190 | }
191 |
192 | async fn action_integrate(dirs: Dirs, action: ActionIntegrate) -> Result<()> {
193 | let mut state = State::init(dirs)?;
194 | let game_pak_path = get_pak_path(&state, &action.fsd_pak)?;
195 | debug!(?game_pak_path);
196 |
197 | let mod_specs = action
198 | .mods
199 | .into_iter()
200 | .map(ModSpecification::new)
201 | .collect::>();
202 |
203 | resolve_unordered_and_integrate_with_provider_init(
204 | game_pak_path,
205 | &mut state,
206 | &mod_specs,
207 | action.update,
208 | init_provider,
209 | )
210 | .await
211 | .map_err(|e| anyhow!("{}", e))
212 | }
213 |
214 | async fn action_integrate_profile(dirs: Dirs, action: ActionIntegrateProfile) -> Result<()> {
215 | let mut state = State::init(dirs)?;
216 | let game_pak_path = get_pak_path(&state, &action.fsd_pak)?;
217 | debug!(?game_pak_path);
218 |
219 | let mut mods = Vec::new();
220 | state.mod_data.for_each_enabled_mod(&action.profile, |mc| {
221 | mods.push(mc.spec.clone());
222 | });
223 |
224 | resolve_unordered_and_integrate_with_provider_init(
225 | game_pak_path,
226 | &mut state,
227 | &mods,
228 | action.update,
229 | init_provider,
230 | )
231 | .await
232 | .map_err(|e| anyhow!("{}", e))
233 | }
234 |
235 | async fn action_lint(dirs: Dirs, action: ActionLint) -> Result<()> {
236 | let mut state = State::init(dirs)?;
237 | let game_pak_path = get_pak_path(&state, &action.fsd_pak)?;
238 | debug!(?game_pak_path);
239 |
240 | let mut mods = Vec::new();
241 | state.mod_data.for_each_mod(&action.profile, |mc| {
242 | mods.push(mc.spec.clone());
243 | });
244 |
245 | let mod_paths = resolve_ordered_with_provider_init(&mut state, &mods, init_provider).await?;
246 |
247 | let report = tokio::task::spawn_blocking(move || {
248 | run_lints(
249 | &BTreeSet::from([
250 | LintId::ARCHIVE_WITH_ONLY_NON_PAK_FILES,
251 | LintId::ASSET_REGISTRY_BIN,
252 | LintId::CONFLICTING,
253 | LintId::EMPTY_ARCHIVE,
254 | LintId::OUTDATED_PAK_VERSION,
255 | LintId::SHADER_FILES,
256 | LintId::ARCHIVE_WITH_MULTIPLE_PAKS,
257 | LintId::NON_ASSET_FILES,
258 | LintId::SPLIT_ASSET_PAIRS,
259 | ]),
260 | mods.into_iter().zip(mod_paths).collect(),
261 | Some(game_pak_path),
262 | )
263 | })
264 | .await??;
265 | println!("{report:#?}");
266 | Ok(())
267 | }
268 |
--------------------------------------------------------------------------------
/src/mod_lints/archive_multiple_paks.rs:
--------------------------------------------------------------------------------
1 | use std::collections::BTreeSet;
2 |
3 | use crate::providers::ModSpecification;
4 |
5 | use super::{Lint, LintCtxt, LintError};
6 |
7 | #[derive(Default)]
8 | pub struct ArchiveMultiplePaksLint;
9 |
10 | impl Lint for ArchiveMultiplePaksLint {
11 | type Output = BTreeSet;
12 |
13 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
14 | let mut archive_multiple_paks_mods = BTreeSet::new();
15 | lcx.for_each_mod(
16 | |_, _, _| Ok(()),
17 | None::,
18 | None::,
19 | Some(|mod_spec| {
20 | archive_multiple_paks_mods.insert(mod_spec);
21 | }),
22 | )?;
23 | Ok(archive_multiple_paks_mods)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/mod_lints/archive_only_non_pak_files.rs:
--------------------------------------------------------------------------------
1 | use std::collections::BTreeSet;
2 |
3 | use crate::providers::ModSpecification;
4 |
5 | use super::{Lint, LintCtxt, LintError};
6 |
7 | #[derive(Default)]
8 | pub struct ArchiveOnlyNonPakFilesLint;
9 |
10 | impl Lint for ArchiveOnlyNonPakFilesLint {
11 | type Output = BTreeSet;
12 |
13 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
14 | let mut archive_only_non_pak_files_mods = BTreeSet::new();
15 | lcx.for_each_mod(
16 | |_, _, _| Ok(()),
17 | None::,
18 | Some(|mod_spec| {
19 | archive_only_non_pak_files_mods.insert(mod_spec);
20 | }),
21 | None::,
22 | )?;
23 | Ok(archive_only_non_pak_files_mods)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/mod_lints/asset_register_bin.rs:
--------------------------------------------------------------------------------
1 | use std::collections::{BTreeMap, BTreeSet};
2 |
3 | use crate::providers::ModSpecification;
4 |
5 | use super::{Lint, LintCtxt, LintError};
6 |
7 | #[derive(Default)]
8 | pub struct AssetRegisterBinLint;
9 |
10 | impl Lint for AssetRegisterBinLint {
11 | type Output = BTreeMap>;
12 |
13 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
14 | let mut asset_register_bin_mods = BTreeMap::new();
15 |
16 | lcx.for_each_mod_file(|mod_spec, _, _, raw_path, normalized_path| {
17 | if let Some(filename) = raw_path.file_name()
18 | && filename == "AssetRegistry.bin"
19 | {
20 | asset_register_bin_mods
21 | .entry(mod_spec.clone())
22 | .and_modify(|paths: &mut BTreeSet| {
23 | paths.insert(normalized_path.clone());
24 | })
25 | .or_insert_with(|| [normalized_path.clone()].into());
26 | }
27 |
28 | Ok(())
29 | })?;
30 |
31 | Ok(asset_register_bin_mods)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/mod_lints/conflicting_mods.rs:
--------------------------------------------------------------------------------
1 | use std::collections::BTreeMap;
2 |
3 | use indexmap::IndexSet;
4 |
5 | use crate::providers::ModSpecification;
6 |
7 | use super::{Lint, LintCtxt, LintError};
8 |
9 | #[derive(Default)]
10 | pub struct ConflictingModsLint;
11 |
12 | const CONFLICTING_MODS_LINT_WHITELIST: [&str; 1] = ["fsd/content/_interop"];
13 |
14 | impl Lint for ConflictingModsLint {
15 | type Output = BTreeMap>;
16 |
17 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
18 | let mut per_path_modifiers = BTreeMap::new();
19 |
20 | lcx.for_each_mod_file(|mod_spec, _, _, _, normalized_path| {
21 | per_path_modifiers
22 | .entry(normalized_path)
23 | .and_modify(|modifiers: &mut IndexSet| {
24 | modifiers.insert(mod_spec.clone());
25 | })
26 | .or_insert_with(|| [mod_spec.clone()].into());
27 | Ok(())
28 | })?;
29 |
30 | let conflicting_mods = per_path_modifiers
31 | .into_iter()
32 | .filter(|(p, _)| {
33 | for whitelisted_path in CONFLICTING_MODS_LINT_WHITELIST {
34 | if p.starts_with(whitelisted_path) {
35 | return false;
36 | }
37 | }
38 | true
39 | })
40 | .filter(|(_, modifiers)| modifiers.len() > 1)
41 | .collect::>>();
42 |
43 | Ok(conflicting_mods)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/mod_lints/empty_archive.rs:
--------------------------------------------------------------------------------
1 | use std::collections::BTreeSet;
2 |
3 | use crate::providers::ModSpecification;
4 |
5 | use super::{Lint, LintCtxt, LintError};
6 |
7 | #[derive(Default)]
8 | pub struct EmptyArchiveLint;
9 |
10 | impl Lint for EmptyArchiveLint {
11 | type Output = BTreeSet;
12 |
13 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
14 | let mut empty_archive_mods = BTreeSet::new();
15 |
16 | lcx.for_each_mod(
17 | |_, _, _| Ok(()),
18 | Some(|mod_spec| {
19 | empty_archive_mods.insert(mod_spec);
20 | }),
21 | None::,
22 | None::,
23 | )?;
24 |
25 | Ok(empty_archive_mods)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/mod_lints/non_asset_files.rs:
--------------------------------------------------------------------------------
1 | use std::collections::{BTreeMap, BTreeSet};
2 |
3 | use crate::providers::ModSpecification;
4 |
5 | use super::{Lint, LintCtxt, LintError};
6 |
7 | #[derive(Default)]
8 | pub struct NonAssetFilesLint;
9 |
10 | const ENDS_WITH_WHITE_LIST: [&str; 7] = [
11 | ".uexp",
12 | ".uasset",
13 | ".ubulk",
14 | ".ufont",
15 | ".locres",
16 | ".ushaderbytecode",
17 | "assetregistry.bin",
18 | ];
19 |
20 | impl Lint for NonAssetFilesLint {
21 | type Output = BTreeMap>;
22 |
23 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
24 | let mut non_asset_files = BTreeMap::new();
25 |
26 | lcx.for_each_mod_file(|mod_spec, _, _, _, normalized_path| {
27 | let is_unreal_asset = ENDS_WITH_WHITE_LIST
28 | .iter()
29 | .any(|end| normalized_path.ends_with(end));
30 | if !is_unreal_asset {
31 | non_asset_files
32 | .entry(mod_spec)
33 | .and_modify(|files: &mut BTreeSet| {
34 | files.insert(normalized_path.clone());
35 | })
36 | .or_insert_with(|| [normalized_path].into());
37 | }
38 | Ok(())
39 | })?;
40 |
41 | Ok(non_asset_files)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/mod_lints/outdated_pak_version.rs:
--------------------------------------------------------------------------------
1 | use std::collections::BTreeMap;
2 |
3 | use crate::providers::ModSpecification;
4 |
5 | use super::{Lint, LintCtxt, LintError};
6 |
7 | #[derive(Default)]
8 | pub struct OutdatedPakVersionLint;
9 |
10 | impl Lint for OutdatedPakVersionLint {
11 | type Output = BTreeMap;
12 |
13 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
14 | let mut outdated_pak_version_mods = BTreeMap::new();
15 |
16 | lcx.for_each_mod(
17 | |mod_spec, _, pak_reader| {
18 | if pak_reader.version() < repak::Version::V11 {
19 | outdated_pak_version_mods.insert(mod_spec.clone(), pak_reader.version());
20 | }
21 | Ok(())
22 | },
23 | None::,
24 | None::,
25 | None::,
26 | )?;
27 |
28 | Ok(outdated_pak_version_mods)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/mod_lints/shader_files.rs:
--------------------------------------------------------------------------------
1 | use std::collections::{BTreeMap, BTreeSet};
2 |
3 | use crate::providers::ModSpecification;
4 |
5 | use super::{Lint, LintCtxt, LintError};
6 |
7 | #[derive(Default)]
8 | pub struct ShaderFilesLint;
9 |
10 | impl Lint for ShaderFilesLint {
11 | type Output = BTreeMap>;
12 |
13 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
14 | let mut shader_file_mods = BTreeMap::new();
15 |
16 | lcx.for_each_mod_file(|mod_spec, _, _, raw_path, normalized_path| {
17 | if raw_path.extension().and_then(std::ffi::OsStr::to_str) == Some("ushaderbytecode") {
18 | shader_file_mods
19 | .entry(mod_spec)
20 | .and_modify(|paths: &mut BTreeSet| {
21 | paths.insert(normalized_path.clone());
22 | })
23 | .or_insert_with(|| [normalized_path].into());
24 | }
25 | Ok(())
26 | })?;
27 |
28 | Ok(shader_file_mods)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/mod_lints/split_asset_pairs.rs:
--------------------------------------------------------------------------------
1 | use std::collections::{BTreeMap, BTreeSet};
2 |
3 | use tracing::trace;
4 |
5 | use crate::providers::ModSpecification;
6 |
7 | use super::{Lint, LintCtxt, LintError};
8 |
9 | #[derive(Default)]
10 | pub struct SplitAssetPairsLint;
11 |
12 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13 | pub enum SplitAssetPair {
14 | MissingUexp,
15 | MissingUasset,
16 | }
17 |
18 | impl Lint for SplitAssetPairsLint {
19 | type Output = BTreeMap>;
20 |
21 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
22 | let mut per_mod_path_without_final_ext_to_exts_map = BTreeMap::new();
23 |
24 | lcx.for_each_mod_file(|mod_spec, _, _, _, normalized_path| {
25 | let mut iter = normalized_path.rsplit('.').take(2);
26 | let Some(final_ext) = iter.next() else {
27 | return Ok(());
28 | };
29 | let Some(path_without_final_ext) = iter.next() else {
30 | return Ok(());
31 | };
32 |
33 | per_mod_path_without_final_ext_to_exts_map
34 | .entry(mod_spec)
35 | .and_modify(|map: &mut BTreeMap>| {
36 | map.entry(path_without_final_ext.to_string())
37 | .and_modify(|exts: &mut BTreeSet| {
38 | exts.insert(final_ext.to_string());
39 | })
40 | .or_insert_with(|| [final_ext.to_string()].into());
41 | })
42 | .or_insert_with(|| {
43 | [(
44 | path_without_final_ext.to_string(),
45 | [final_ext.to_string()].into(),
46 | )]
47 | .into()
48 | });
49 |
50 | Ok(())
51 | })?;
52 |
53 | let mut split_asset_pairs_mods = BTreeMap::new();
54 |
55 | for (mod_spec, map) in per_mod_path_without_final_ext_to_exts_map {
56 | for (path_without_final_ext, final_exts) in map {
57 | split_asset_pairs_mods
58 | .entry(mod_spec.clone())
59 | .and_modify(|map: &mut BTreeMap| {
60 | match (final_exts.contains("uexp"), final_exts.contains("uasset")) {
61 | (true, false) => {
62 | map.insert(
63 | format!("{path_without_final_ext}.uexp"),
64 | SplitAssetPair::MissingUasset,
65 | );
66 | }
67 | (false, true) => {
68 | map.insert(
69 | format!("{path_without_final_ext}.uasset"),
70 | SplitAssetPair::MissingUexp,
71 | );
72 | }
73 | _ => {}
74 | }
75 | })
76 | .or_insert_with(|| {
77 | match (final_exts.contains("uexp"), final_exts.contains("uasset")) {
78 | (true, false) => [(
79 | format!("{path_without_final_ext}.uexp"),
80 | SplitAssetPair::MissingUasset,
81 | )]
82 | .into(),
83 | (false, true) => [(
84 | format!("{path_without_final_ext}.uasset"),
85 | SplitAssetPair::MissingUexp,
86 | )]
87 | .into(),
88 | _ => BTreeMap::default(),
89 | }
90 | });
91 | }
92 | }
93 |
94 | split_asset_pairs_mods.retain(|_, map| !map.is_empty());
95 |
96 | trace!("split_asset_pairs_mods:\n{:#?}", split_asset_pairs_mods);
97 |
98 | Ok(split_asset_pairs_mods)
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/mod_lints/unmodified_game_assets.rs:
--------------------------------------------------------------------------------
1 | use std::borrow::Cow;
2 | use std::collections::{BTreeMap, BTreeSet};
3 | use std::io::BufReader;
4 | use std::path::PathBuf;
5 |
6 | use fs_err as fs;
7 | use path_slash::PathExt;
8 | use rayon::prelude::*;
9 | use sha2::Digest;
10 | use tracing::trace;
11 |
12 | use crate::providers::ModSpecification;
13 |
14 | use super::{InvalidGamePathSnafu, Lint, LintCtxt, LintError};
15 |
16 | #[derive(Default)]
17 | pub struct UnmodifiedGameAssetsLint;
18 |
19 | impl Lint for UnmodifiedGameAssetsLint {
20 | type Output = BTreeMap>;
21 |
22 | fn check_mods(&mut self, lcx: &LintCtxt) -> Result {
23 | let Some(game_pak_path) = &lcx.fsd_pak_path else {
24 | InvalidGamePathSnafu.fail()?
25 | };
26 |
27 | // Adapted from
28 | // .
29 | let mut reader = BufReader::new(fs::File::open(game_pak_path)?);
30 | let pak = repak::PakBuilder::new().reader(&mut reader)?;
31 |
32 | let mount_point = PathBuf::from(pak.mount_point());
33 |
34 | let full_paths = pak
35 | .files()
36 | .into_iter()
37 | .map(|f| (mount_point.join(&f), f))
38 | .collect::>();
39 | let stripped = full_paths
40 | .iter()
41 | .map(|(full_path, _path)| full_path.strip_prefix("../../../"))
42 | .collect::, _>>()?;
43 |
44 | let game_file_hashes: std::sync::Arc<
45 | std::sync::Mutex, Vec>>,
46 | > = Default::default();
47 |
48 | full_paths.par_iter().zip(stripped).try_for_each_init(
49 | || (game_file_hashes.clone(), fs::File::open(game_pak_path)),
50 | |(hashes, file), ((_full_path, path), stripped)| -> Result<(), repak::Error> {
51 | let mut hasher = sha2::Sha256::new();
52 | pak.read_file(
53 | path,
54 | &mut BufReader::new(file.as_ref().unwrap()),
55 | &mut hasher,
56 | )?;
57 | let hash = hasher.finalize();
58 | hashes
59 | .lock()
60 | .unwrap()
61 | .insert(stripped.to_slash_lossy(), hash.to_vec());
62 | Ok(())
63 | },
64 | )?;
65 |
66 | let mut unmodified_game_assets = BTreeMap::new();
67 |
68 | lcx.for_each_mod_file(
69 | |mod_spec, mut pak_read_seek, pak_reader, _, normalized_path| {
70 | if let Some(reference_hash) = game_file_hashes
71 | .lock()
72 | .unwrap()
73 | .get(&Cow::Owned(normalized_path.clone()))
74 | {
75 | let mut hasher = sha2::Sha256::new();
76 | pak_reader.read_file(&normalized_path, &mut pak_read_seek, &mut hasher)?;
77 | let mod_file_hash = hasher.finalize().to_vec();
78 |
79 | if &mod_file_hash == reference_hash {
80 | unmodified_game_assets
81 | .entry(mod_spec)
82 | .and_modify(|paths: &mut BTreeSet| {
83 | paths.insert(normalized_path.clone());
84 | })
85 | .or_insert_with(|| [normalized_path].into());
86 | }
87 | }
88 |
89 | Ok(())
90 | },
91 | )?;
92 |
93 | trace!("unmodified_game_assets:\n{:#?}", unmodified_game_assets);
94 |
95 | Ok(unmodified_game_assets)
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/providers/cache.rs:
--------------------------------------------------------------------------------
1 | use std::collections::HashMap;
2 | use std::ops::{Deref, DerefMut};
3 | use std::path::{Path, PathBuf};
4 | use std::sync::{Arc, RwLock};
5 |
6 | use fs_err as fs;
7 | use serde::{Deserialize, Serialize};
8 | use snafu::prelude::*;
9 |
10 | use crate::state::config::ConfigWrapper;
11 |
12 | pub type ProviderCache = Arc>>;
13 |
14 | #[typetag::serde(tag = "type")]
15 | pub trait ModProviderCache: Sync + Send + std::fmt::Debug {
16 | fn new() -> Self
17 | where
18 | Self: Sized;
19 | fn as_any(&self) -> &dyn std::any::Any;
20 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
21 | }
22 |
23 | #[obake::versioned]
24 | #[obake(version("0.0.0"))]
25 | #[derive(Debug, Default, Serialize, Deserialize)]
26 | pub struct Cache {
27 | pub(super) cache: HashMap>,
28 | }
29 |
30 | impl Cache {
31 | pub(super) fn has(&self, id: &str) -> bool {
32 | self.cache
33 | .get(id)
34 | .and_then(|c| c.as_any().downcast_ref::())
35 | .is_none()
36 | }
37 |
38 | pub(super) fn get(&self, id: &str) -> Option<&T> {
39 | self.cache
40 | .get(id)
41 | .and_then(|c| c.as_any().downcast_ref::())
42 | }
43 |
44 | pub(super) fn get_mut(&mut self, id: &str) -> &mut T {
45 | if self.has::(id) {
46 | self.cache.insert(id.to_owned(), Box::new(T::new()));
47 | }
48 | self.cache
49 | .get_mut(id)
50 | .and_then(|c| c.as_any_mut().downcast_mut::())
51 | .unwrap()
52 | }
53 | }
54 |
55 | #[derive(Debug, Serialize, Deserialize)]
56 | #[serde(tag = "version")]
57 | pub enum VersionAnnotatedCache {
58 | #[serde(rename = "0.0.0")]
59 | V0_0_0(Cache!["0.0.0"]),
60 | }
61 |
62 | impl Default for VersionAnnotatedCache {
63 | fn default() -> Self {
64 | VersionAnnotatedCache::V0_0_0(Default::default())
65 | }
66 | }
67 |
68 | impl Deref for VersionAnnotatedCache {
69 | type Target = Cache!["0.0.0"];
70 |
71 | fn deref(&self) -> &Self::Target {
72 | match self {
73 | VersionAnnotatedCache::V0_0_0(c) => c,
74 | }
75 | }
76 | }
77 |
78 | impl DerefMut for VersionAnnotatedCache {
79 | fn deref_mut(&mut self) -> &mut Self::Target {
80 | match self {
81 | VersionAnnotatedCache::V0_0_0(c) => c,
82 | }
83 | }
84 | }
85 |
86 | #[derive(Debug, Serialize, Deserialize)]
87 | #[serde(untagged)]
88 | pub enum MaybeVersionedCache {
89 | Versioned(VersionAnnotatedCache),
90 | Legacy(Cache!["0.0.0"]),
91 | }
92 |
93 | impl Default for MaybeVersionedCache {
94 | fn default() -> Self {
95 | MaybeVersionedCache::Versioned(Default::default())
96 | }
97 | }
98 |
99 | #[derive(Debug, Snafu)]
100 | pub enum CacheError {
101 | #[snafu(display("failed to read cache.json with provided path {}", search_path.display()))]
102 | CacheJsonReadFailed {
103 | source: std::io::Error,
104 | search_path: PathBuf,
105 | },
106 | #[snafu(display("failed to deserialize cache.json into dynamic JSON value: {reason}"))]
107 | DeserializeJsonFailed {
108 | #[snafu(source(false))]
109 | source: Option,
110 | reason: &'static str,
111 | },
112 | #[snafu(display("failed attempt to deserialize as legacy cache format"))]
113 | DeserializeLegacyCacheFailed { source: serde_json::Error },
114 | #[snafu(display("failed to deserialize as cache {version} format"))]
115 | DeserializeVersionedCacheFailed {
116 | source: serde_json::Error,
117 | version: &'static str,
118 | },
119 | }
120 |
121 | pub(crate) fn read_cache_metadata_or_default(
122 | cache_metadata_path: &PathBuf,
123 | ) -> Result {
124 | let cache: MaybeVersionedCache = match fs::read(cache_metadata_path) {
125 | Ok(buf) => {
126 | let mut dyn_value = match serde_json::from_slice::(&buf) {
127 | Ok(dyn_value) => dyn_value,
128 | Err(e) => {
129 | return Err(CacheError::DeserializeJsonFailed {
130 | source: Some(e),
131 | reason: "malformed JSON",
132 | })
133 | }
134 | };
135 | let Some(obj_map) = dyn_value.as_object_mut() else {
136 | return Err(CacheError::DeserializeJsonFailed {
137 | source: None,
138 | reason: "failed to deserialize into object map",
139 | });
140 | };
141 | let version = obj_map.remove("version");
142 | if let Some(v) = version
143 | && let serde_json::Value::String(vs) = v
144 | {
145 | match vs.as_str() {
146 | "0.0.0" => {
147 | // HACK: workaround a serde issue relating to flattening with tags
148 | // involving numeric keys in hashmaps, see
149 | // .
150 | match serde_json::from_slice::(&buf) {
151 | Ok(c) => {
152 | MaybeVersionedCache::Versioned(VersionAnnotatedCache::V0_0_0(c))
153 | }
154 | Err(e) => Err(e).context(DeserializeVersionedCacheFailedSnafu {
155 | version: "v0.0.0",
156 | })?,
157 | }
158 | }
159 | _ => unimplemented!(),
160 | }
161 | } else {
162 | // HACK: workaround a serde issue relating to flattening with tags involving
163 | // numeric keys in hashmaps, see .
164 | match serde_json::from_slice::>>(&buf) {
165 | Ok(c) => MaybeVersionedCache::Legacy(Cache_v0_0_0 { cache: c }),
166 | Err(e) => Err(e).context(DeserializeLegacyCacheFailedSnafu)?,
167 | }
168 | }
169 | }
170 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => MaybeVersionedCache::default(),
171 | Err(e) => Err(e).context(CacheJsonReadFailedSnafu {
172 | search_path: cache_metadata_path.to_owned(),
173 | })?,
174 | };
175 |
176 | let cache: VersionAnnotatedCache = match cache {
177 | MaybeVersionedCache::Versioned(v) => match v {
178 | VersionAnnotatedCache::V0_0_0(v) => VersionAnnotatedCache::V0_0_0(v),
179 | },
180 | MaybeVersionedCache::Legacy(legacy) => VersionAnnotatedCache::V0_0_0(legacy),
181 | };
182 |
183 | Ok(cache)
184 | }
185 |
186 | #[derive(Debug, Serialize, Deserialize)]
187 | pub struct BlobRef(String);
188 |
189 | #[derive(Debug, Snafu)]
190 | #[snafu(display("blob cache {kind} failed"))]
191 | pub struct BlobCacheError {
192 | source: std::io::Error,
193 | kind: &'static str,
194 | }
195 |
196 | #[derive(Debug, Clone)]
197 | pub struct BlobCache {
198 | path: PathBuf,
199 | }
200 |
201 | impl BlobCache {
202 | pub(super) fn new>(path: P) -> Self {
203 | fs::create_dir(&path).ok();
204 | Self {
205 | path: path.as_ref().to_path_buf(),
206 | }
207 | }
208 |
209 | pub(super) fn write(&self, blob: &[u8]) -> Result {
210 | use sha2::{Digest, Sha256};
211 |
212 | let mut hasher = Sha256::new();
213 | hasher.update(blob);
214 | let hash = hex::encode(hasher.finalize());
215 |
216 | let tmp = self.path.join(format!(".{hash}"));
217 | fs::write(&tmp, blob).context(BlobCacheSnafu { kind: "write" })?;
218 | fs::rename(tmp, self.path.join(&hash)).context(BlobCacheSnafu { kind: "rename" })?;
219 |
220 | Ok(BlobRef(hash))
221 | }
222 |
223 | pub(super) fn get_path(&self, blob: &BlobRef) -> Option {
224 | let path = self.path.join(&blob.0);
225 | path.exists().then_some(path)
226 | }
227 | }
228 |
--------------------------------------------------------------------------------
/src/providers/file.rs:
--------------------------------------------------------------------------------
1 | use std::collections::HashMap;
2 | use std::path::{Path, PathBuf};
3 | use std::sync::Arc;
4 |
5 | use tokio::sync::mpsc::Sender;
6 |
7 | use super::{
8 | BlobCache, FetchProgress, ModInfo, ModProvider, ModResolution, ModResponse, ModSpecification,
9 | ProviderCache, ProviderError,
10 | };
11 |
12 | inventory::submit! {
13 | super::ProviderFactory {
14 | id: FILE_PROVIDER_ID,
15 | new: FileProvider::new_provider,
16 | can_provide: |url| Path::new(url).exists(),
17 | parameters: &[],
18 | }
19 | }
20 |
21 | #[derive(Debug)]
22 | pub struct FileProvider {}
23 |
24 | impl FileProvider {
25 | pub fn new_provider(
26 | _parameters: &HashMap,
27 | ) -> Result, ProviderError> {
28 | Ok(Arc::new(Self::new()))
29 | }
30 |
31 | pub fn new() -> Self {
32 | Self {}
33 | }
34 | }
35 |
36 | const FILE_PROVIDER_ID: &str = "file";
37 |
38 | #[async_trait::async_trait]
39 | impl ModProvider for FileProvider {
40 | async fn resolve_mod(
41 | &self,
42 | spec: &ModSpecification,
43 | _update: bool,
44 | _cache: ProviderCache,
45 | ) -> Result {
46 | let path = Path::new(&spec.url);
47 | let name = path
48 | .file_name()
49 | .map(|s| s.to_string_lossy().to_string())
50 | .unwrap_or_else(|| spec.url.to_string());
51 | Ok(ModResponse::Resolve(ModInfo {
52 | provider: FILE_PROVIDER_ID,
53 | name,
54 | spec: spec.clone(),
55 | versions: vec![],
56 | resolution: ModResolution::unresolvable(
57 | spec.url.clone().into(),
58 | path.file_name()
59 | .map(|p| p.to_string_lossy().to_string())
60 | .unwrap_or_else(|| "unknown".to_string()),
61 | ),
62 | suggested_require: false,
63 | suggested_dependencies: vec![],
64 | modio_tags: None,
65 | modio_id: None,
66 | }))
67 | }
68 |
69 | async fn fetch_mod(
70 | &self,
71 | res: &ModResolution,
72 | _update: bool,
73 | _cache: ProviderCache,
74 | _blob_cache: &BlobCache,
75 | tx: Option>,
76 | ) -> Result {
77 | if let Some(tx) = tx {
78 | tx.send(FetchProgress::Complete {
79 | resolution: res.clone(),
80 | })
81 | .await
82 | .unwrap();
83 | }
84 | Ok(PathBuf::from(&res.url.0))
85 | }
86 |
87 | async fn update_cache(&self, _cache: ProviderCache) -> Result<(), ProviderError> {
88 | Ok(())
89 | }
90 |
91 | async fn check(&self) -> Result<(), ProviderError> {
92 | Ok(())
93 | }
94 |
95 | fn get_mod_info(&self, spec: &ModSpecification, _cache: ProviderCache) -> Option {
96 | let path = Path::new(&spec.url);
97 | let name = path
98 | .file_name()
99 | .map(|s| s.to_string_lossy().to_string())
100 | .unwrap_or_else(|| spec.url.to_string());
101 | Some(ModInfo {
102 | provider: FILE_PROVIDER_ID,
103 | name,
104 | spec: spec.clone(),
105 | versions: vec![],
106 | resolution: ModResolution::unresolvable(
107 | spec.url.clone().into(),
108 | path.file_name()
109 | .map(|p| p.to_string_lossy().to_string())
110 | .unwrap_or_else(|| "unknown".to_string()),
111 | ),
112 | suggested_require: false,
113 | suggested_dependencies: vec![],
114 | modio_tags: None,
115 | modio_id: None,
116 | })
117 | }
118 |
119 | fn is_pinned(&self, _spec: &ModSpecification, _cache: ProviderCache) -> bool {
120 | true
121 | }
122 |
123 | fn get_version_name(&self, _spec: &ModSpecification, _cache: ProviderCache) -> Option {
124 | Some("latest".to_string())
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/providers/http.rs:
--------------------------------------------------------------------------------
1 | use std::sync::OnceLock;
2 |
3 | use serde::{Deserialize, Serialize};
4 | use tracing::info;
5 |
6 | use crate::providers::*;
7 |
8 | inventory::submit! {
9 | super::ProviderFactory {
10 | id: "http",
11 | new: HttpProvider::new_provider,
12 | can_provide: |url| -> bool {
13 | re_mod()
14 | .captures(url)
15 | .and_then(|c| c.name("hostname"))
16 | .is_some_and(|h| !["mod.io", "drg.mod.io", "drg.old.mod.io"].contains(&h.as_str()))
17 | },
18 | parameters: &[],
19 | }
20 | }
21 |
22 | #[derive(Debug, Default, Serialize, Deserialize)]
23 | pub struct HttpProviderCache {
24 | url_blobs: HashMap,
25 | }
26 |
27 | #[typetag::serde]
28 | impl ModProviderCache for HttpProviderCache {
29 | fn new() -> Self {
30 | Default::default()
31 | }
32 |
33 | fn as_any(&self) -> &dyn std::any::Any {
34 | self
35 | }
36 |
37 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
38 | self
39 | }
40 | }
41 |
42 | #[derive(Debug)]
43 | pub struct HttpProvider {
44 | client: reqwest::Client,
45 | }
46 |
47 | impl HttpProvider {
48 | pub fn new_provider(
49 | _parameters: &HashMap,
50 | ) -> Result, ProviderError> {
51 | Ok(Arc::new(Self::new()))
52 | }
53 |
54 | pub fn new() -> Self {
55 | Self {
56 | client: reqwest::Client::new(),
57 | }
58 | }
59 | }
60 |
61 | static RE_MOD: OnceLock = OnceLock::new();
62 | fn re_mod() -> &'static regex::Regex {
63 | RE_MOD.get_or_init(|| regex::Regex::new(r"^https?://(?P[^/]+)(/|$)").unwrap())
64 | }
65 |
66 | const HTTP_PROVIDER_ID: &str = "http";
67 |
68 | #[async_trait::async_trait]
69 | impl ModProvider for HttpProvider {
70 | async fn resolve_mod(
71 | &self,
72 | spec: &ModSpecification,
73 | _update: bool,
74 | _cache: ProviderCache,
75 | ) -> Result {
76 | let Ok(url) = url::Url::parse(&spec.url) else {
77 | return Err(ProviderError::InvalidUrl {
78 | url: spec.url.to_string(),
79 | });
80 | };
81 |
82 | let name = url
83 | .path_segments()
84 | .and_then(|mut s| s.next_back())
85 | .map(|s| s.to_string())
86 | .unwrap_or_else(|| url.to_string());
87 |
88 | Ok(ModResponse::Resolve(ModInfo {
89 | provider: HTTP_PROVIDER_ID,
90 | name,
91 | spec: spec.clone(),
92 | versions: vec![],
93 | resolution: ModResolution::resolvable(spec.url.as_str().into()),
94 | suggested_require: false,
95 | suggested_dependencies: vec![],
96 | modio_tags: None,
97 | modio_id: None,
98 | }))
99 | }
100 |
101 | async fn fetch_mod(
102 | &self,
103 | res: &ModResolution,
104 | update: bool,
105 | cache: ProviderCache,
106 | blob_cache: &BlobCache,
107 | tx: Option>,
108 | ) -> Result {
109 | let url = &res.url;
110 | Ok(
111 | if let Some(path) = if update {
112 | None
113 | } else {
114 | cache
115 | .read()
116 | .unwrap()
117 | .get::(HTTP_PROVIDER_ID)
118 | .and_then(|c| c.url_blobs.get(&url.0))
119 | .and_then(|r| blob_cache.get_path(r))
120 | } {
121 | if let Some(tx) = tx {
122 | tx.send(FetchProgress::Complete {
123 | resolution: res.clone(),
124 | })
125 | .await
126 | .unwrap();
127 | }
128 | path
129 | } else {
130 | info!("downloading mod {url:?}...");
131 | let response = self
132 | .client
133 | .get(&url.0)
134 | .send()
135 | .await
136 | .context(RequestFailedSnafu {
137 | url: url.0.to_string(),
138 | })?
139 | .error_for_status()
140 | .context(ResponseSnafu {
141 | url: url.0.to_string(),
142 | })?;
143 | let size = response.content_length(); // TODO will be incorrect if compressed
144 | if let Some(mime) = response
145 | .headers()
146 | .get(reqwest::header::HeaderName::from_static("content-type"))
147 | {
148 | let content_type = mime.to_str().context(InvalidMimeSnafu {
149 | url: url.0.to_string(),
150 | })?;
151 | ensure!(
152 | ["application/zip", "application/octet-stream"].contains(&content_type),
153 | UnexpectedContentTypeSnafu {
154 | found_content_type: content_type.to_string(),
155 | url: url.0.to_string(),
156 | }
157 | );
158 | }
159 |
160 | use futures::stream::TryStreamExt;
161 | use tokio::io::AsyncWriteExt;
162 |
163 | let mut cursor = std::io::Cursor::new(vec![]);
164 | let mut stream = response.bytes_stream();
165 | while let Some(bytes) = stream.try_next().await.with_context(|_| FetchSnafu {
166 | url: url.0.to_string(),
167 | })? {
168 | cursor
169 | .write_all(&bytes)
170 | .await
171 | .with_context(|_| BufferIoSnafu {
172 | url: url.0.to_string(),
173 | })?;
174 | if let Some(size) = size
175 | && let Some(tx) = &tx
176 | {
177 | tx.send(FetchProgress::Progress {
178 | resolution: res.clone(),
179 | progress: cursor.get_ref().len() as u64,
180 | size,
181 | })
182 | .await
183 | .unwrap();
184 | }
185 | }
186 |
187 | let blob = blob_cache.write(&cursor.into_inner())?;
188 | let path = blob_cache.get_path(&blob).unwrap();
189 | cache
190 | .write()
191 | .unwrap()
192 | .get_mut::(HTTP_PROVIDER_ID)
193 | .url_blobs
194 | .insert(url.0.to_owned(), blob);
195 |
196 | if let Some(tx) = tx {
197 | tx.send(FetchProgress::Complete {
198 | resolution: res.clone(),
199 | })
200 | .await
201 | .unwrap();
202 | }
203 | path
204 | },
205 | )
206 | }
207 |
208 | async fn update_cache(&self, _cache: ProviderCache) -> Result<(), ProviderError> {
209 | Ok(())
210 | }
211 |
212 | async fn check(&self) -> Result<(), ProviderError> {
213 | Ok(())
214 | }
215 |
216 | fn get_mod_info(&self, spec: &ModSpecification, _cache: ProviderCache) -> Option {
217 | let url = url::Url::parse(&spec.url).ok()?;
218 | let name = url
219 | .path_segments()
220 | .and_then(|mut s| s.next_back())
221 | .map(|s| s.to_string())
222 | .unwrap_or_else(|| url.to_string());
223 | Some(ModInfo {
224 | provider: HTTP_PROVIDER_ID,
225 | name,
226 | spec: spec.clone(),
227 | versions: vec![],
228 | resolution: ModResolution::resolvable(spec.url.as_str().into()),
229 | suggested_require: false,
230 | suggested_dependencies: vec![],
231 | modio_tags: None,
232 | modio_id: None,
233 | })
234 | }
235 |
236 | fn is_pinned(&self, _spec: &ModSpecification, _cache: ProviderCache) -> bool {
237 | true
238 | }
239 |
240 | fn get_version_name(&self, _spec: &ModSpecification, _cache: ProviderCache) -> Option {
241 | Some("latest".to_string())
242 | }
243 | }
244 |
--------------------------------------------------------------------------------
/src/providers/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod file;
2 | pub mod http;
3 | pub mod modio;
4 | #[macro_use]
5 | pub mod cache;
6 | pub mod mod_store;
7 |
8 | use snafu::prelude::*;
9 | use tokio::sync::mpsc::Sender;
10 |
11 | use std::collections::HashMap;
12 | use std::io::{Read, Seek};
13 | use std::path::PathBuf;
14 | use std::sync::{Arc, RwLock};
15 |
16 | pub use cache::*;
17 | pub use mint_lib::mod_info::*;
18 | pub use mod_store::*;
19 |
20 | use self::modio::DrgModioError;
21 |
22 | type Providers = RwLock>>;
23 |
24 | pub trait ReadSeek: Read + Seek + Send {}
25 | impl ReadSeek for T {}
26 |
27 | #[derive(Debug)]
28 | pub enum FetchProgress {
29 | Progress {
30 | resolution: ModResolution,
31 | progress: u64,
32 | size: u64,
33 | },
34 | Complete {
35 | resolution: ModResolution,
36 | },
37 | }
38 |
39 | impl FetchProgress {
40 | pub fn resolution(&self) -> &ModResolution {
41 | match self {
42 | FetchProgress::Progress { resolution, .. } => resolution,
43 | FetchProgress::Complete { resolution, .. } => resolution,
44 | }
45 | }
46 | }
47 |
48 | #[async_trait::async_trait]
49 | pub trait ModProvider: Send + Sync {
50 | async fn resolve_mod(
51 | &self,
52 | spec: &ModSpecification,
53 | update: bool,
54 | cache: ProviderCache,
55 | ) -> Result;
56 | async fn fetch_mod(
57 | &self,
58 | url: &ModResolution,
59 | update: bool,
60 | cache: ProviderCache,
61 | blob_cache: &BlobCache,
62 | tx: Option>,
63 | ) -> Result;
64 | async fn update_cache(&self, cache: ProviderCache) -> Result<(), ProviderError>;
65 | /// Check if provider is configured correctly
66 | async fn check(&self) -> Result<(), ProviderError>;
67 | fn get_mod_info(&self, spec: &ModSpecification, cache: ProviderCache) -> Option;
68 | fn is_pinned(&self, spec: &ModSpecification, cache: ProviderCache) -> bool;
69 | fn get_version_name(&self, spec: &ModSpecification, cache: ProviderCache) -> Option;
70 | }
71 |
72 | #[derive(Debug, Snafu)]
73 | pub enum ProviderError {
74 | #[snafu(display("failed to initialize provider {id} with parameters {parameters:?}"))]
75 | InitProviderFailed {
76 | id: &'static str,
77 | parameters: HashMap,
78 | },
79 | #[snafu(transparent)]
80 | CacheError { source: CacheError },
81 | #[snafu(transparent)]
82 | DrgModioError { source: DrgModioError },
83 | #[snafu(display("mod.io-related error encountered while working on mod {mod_id}: {source}"))]
84 | ModCtxtModioError { source: ::modio::Error, mod_id: u32 },
85 | #[snafu(display("I/O error encountered while working on mod {mod_id}: {source}"))]
86 | ModCtxtIoError { source: std::io::Error, mod_id: u32 },
87 | #[snafu(transparent)]
88 | BlobCacheError { source: BlobCacheError },
89 | #[snafu(display("could not find mod provider for {url}"))]
90 | ProviderNotFound { url: String },
91 | NoProvider {
92 | url: String,
93 | factory: &'static ProviderFactory,
94 | },
95 | #[snafu(display("invalid url <{url}>"))]
96 | InvalidUrl { url: String },
97 | #[snafu(display("request for <{url}> failed: {source}"))]
98 | RequestFailed { source: reqwest::Error, url: String },
99 | #[snafu(display("response from <{url}> failed: {source}"))]
100 | ResponseError { source: reqwest::Error, url: String },
101 | #[snafu(display("mime from <{url}> contains non-ascii characters"))]
102 | InvalidMime {
103 | source: reqwest::header::ToStrError,
104 | url: String,
105 | },
106 | #[snafu(display("unexpected content type from <{url}>: {found_content_type}"))]
107 | UnexpectedContentType {
108 | found_content_type: String,
109 | url: String,
110 | },
111 | #[snafu(display("error while fetching mod <{url}>"))]
112 | FetchError { source: reqwest::Error, url: String },
113 | #[snafu(display("error processing <{url}> while writing to local buffer"))]
114 | BufferIoError { source: std::io::Error, url: String },
115 | #[snafu(display("preview mod links cannot be added directly, please subscribe to the mod on mod.io and and then use the non-preview link"))]
116 | PreviewLink { url: String },
117 | #[snafu(display("mod <{url}> does not have an associated modfile"))]
118 | NoAssociatedModfile { url: String },
119 | #[snafu(display("multiple mods returned for name \"{name_id}\""))]
120 | AmbiguousModNameId { name_id: String },
121 | #[snafu(display("no mods returned for name \"{name_id}\""))]
122 | NoModsForNameId { name_id: String },
123 | }
124 |
125 | impl ProviderError {
126 | pub fn opt_mod_id(&self) -> Option {
127 | match self {
128 | ProviderError::DrgModioError { source } => source.opt_mod_id(),
129 | ProviderError::ModCtxtModioError { mod_id, .. }
130 | | ProviderError::ModCtxtIoError { mod_id, .. } => Some(*mod_id),
131 | _ => None,
132 | }
133 | }
134 | }
135 |
136 | #[derive(Clone)]
137 | pub struct ProviderFactory {
138 | pub id: &'static str,
139 | #[allow(clippy::type_complexity)]
140 | new: fn(&HashMap) -> Result, ProviderError>,
141 | can_provide: fn(&str) -> bool,
142 | pub parameters: &'static [ProviderParameter<'static>],
143 | }
144 |
145 | impl std::fmt::Debug for ProviderFactory {
146 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 | f.debug_struct("ProviderFactory")
148 | .field("id", &self.id)
149 | .field("parameters", &self.parameters)
150 | .finish()
151 | }
152 | }
153 |
154 | #[derive(Debug, Clone)]
155 | pub struct ProviderParameter<'a> {
156 | pub id: &'a str,
157 | pub name: &'a str,
158 | pub description: &'a str,
159 | pub link: Option<&'a str>,
160 | }
161 |
162 | inventory::collect!(ProviderFactory);
163 |
--------------------------------------------------------------------------------
/src/providers/mod_store.rs:
--------------------------------------------------------------------------------
1 | use std::collections::HashSet;
2 | use std::path::Path;
3 |
4 | use snafu::prelude::*;
5 | use tracing::*;
6 |
7 | use crate::providers::*;
8 | use crate::state::config::ConfigWrapper;
9 |
10 | pub struct ModStore {
11 | providers: Providers,
12 | cache: ProviderCache,
13 | blob_cache: BlobCache,
14 | }
15 |
16 | impl ModStore {
17 | pub fn new>(
18 | cache_path: P,
19 | parameters: &HashMap>,
20 | ) -> Result {
21 | let mut providers = HashMap::new();
22 | for prov in Self::get_provider_factories() {
23 | let params = parameters.get(prov.id).cloned().unwrap_or_default();
24 | if prov.parameters.iter().all(|p| params.contains_key(p.id)) {
25 | let Ok(provider) = (prov.new)(¶ms) else {
26 | return Err(ProviderError::InitProviderFailed {
27 | id: prov.id,
28 | parameters: params.to_owned(),
29 | });
30 | };
31 | providers.insert(prov.id, provider);
32 | }
33 | }
34 |
35 | let cache_metadata_path = cache_path.as_ref().join("cache.json");
36 |
37 | let cache = read_cache_metadata_or_default(&cache_metadata_path)?;
38 | let cache = ConfigWrapper::new(&cache_metadata_path, cache);
39 | cache.save().unwrap();
40 |
41 | Ok(Self {
42 | providers: RwLock::new(providers),
43 | cache: Arc::new(RwLock::new(cache)),
44 | blob_cache: BlobCache::new(cache_path.as_ref().join("blobs")),
45 | })
46 | }
47 |
48 | pub fn get_provider_factories() -> impl Iterator- {
49 | inventory::iter::()
50 | }
51 |
52 | pub fn add_provider(
53 | &self,
54 | provider_factory: &ProviderFactory,
55 | parameters: &HashMap,
56 | ) -> Result<(), ProviderError> {
57 | let provider = (provider_factory.new)(parameters)?;
58 | self.providers
59 | .write()
60 | .unwrap()
61 | .insert(provider_factory.id, provider);
62 | Ok(())
63 | }
64 |
65 | pub async fn add_provider_checked(
66 | &self,
67 | provider_factory: &ProviderFactory,
68 | parameters: &HashMap,
69 | ) -> Result<(), ProviderError> {
70 | let provider = (provider_factory.new)(parameters)?;
71 | provider.check().await?;
72 | self.providers
73 | .write()
74 | .unwrap()
75 | .insert(provider_factory.id, provider);
76 | Ok(())
77 | }
78 |
79 | pub fn get_provider(&self, url: &str) -> Result, ProviderError> {
80 | let factory = Self::get_provider_factories()
81 | .find(|f| (f.can_provide)(url))
82 | .context(ProviderNotFoundSnafu {
83 | url: url.to_string(),
84 | })?;
85 | let lock = self.providers.read().unwrap();
86 | Ok(match lock.get(factory.id) {
87 | Some(e) => e.clone(),
88 | None => NoProviderSnafu {
89 | url: url.to_string(),
90 | factory,
91 | }
92 | .fail()?,
93 | })
94 | }
95 |
96 | pub async fn resolve_mods(
97 | &self,
98 | mods: &[ModSpecification],
99 | update: bool,
100 | ) -> Result, ProviderError> {
101 | use futures::stream::{self, StreamExt, TryStreamExt};
102 |
103 | let mut to_resolve = mods.iter().cloned().collect::>();
104 | let mut mods_map = HashMap::new();
105 |
106 | // used to deduplicate dependencies from mods already present in the mod list
107 | let mut precise_mod_specs = HashSet::new();
108 |
109 | while !to_resolve.is_empty() {
110 | for (u, m) in stream::iter(
111 | to_resolve
112 | .iter()
113 | .map(|u| self.resolve_mod(u.to_owned(), update)),
114 | )
115 | .boxed()
116 | .buffer_unordered(5)
117 | .try_collect::