feat: instances v2 (#6431)

* feat: base of instances v2

* feat: use old profiles with compat layer

* prototype: instances v2

* fix: install_from using profile

* fix: skins migration fix

* fix: frontend still using profile path

* fix: add update proj multiselect guard

* fix: cargo fmt

* fix: content missing fields

* feat: break up app-lib/api/instance.rs

* fix: check_content_updates mismatch

* fix: updater modal cleanup w/new structure

* feat: better update all handling

* fix: remove preview_update_all

* fix: feedback on bulk update + lint

* fix: rem transitions

* fix: change to jsonb

* feat: app db backup after update

* fix: lint

* fix: sqlx prepare + use sqlx macros

* fix: lint

* fix: bugs

* feat: defuck the installing process up

* fix: bug of hell

* fix: shear

* fix: fmt

* fix: install progress spacing + change mc/content/overrides to bytes

* fix: lint

* fix: prepr

* fix: navtabs anim not working in app

* fix: worlds.vue improvements + browse page fixes

* feat: optimise queries + adapter fns

* fix: lint

* fix: lint

* feat: shared modrinth-content-management crate (#6469)

* feat: disable warnings setting

* feat: add instances shortcuts (#6329)

* Add modrinth://launch deep link to start a profile

Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck.

* Change route to /launch/profile/{id} for future extensibility

* fix: ensure profile path is url decoded

* fix: URL-decode profile path from deep link

* fix: use urlencoding crate for URL decoding

* feat: implement app instance shortcuts

* feat: change windows shortcut creation to use windows api instead

* feat: implement creating a shortcut launching world/server

* format

* fmt

* fix multiline inline tables

* pnpm prepr

* feat: move create shortcut to last item

* refactor: split up shortcuts.rs for individual platforms

* refactor: turn profile launch url into url type

* use string literal and add safety comment

* pt2

* refactor: rename anything that's profile into instance

* update mac shortcut

---------

Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>

---------

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-25 21:19:29 +00:00
committed by GitHub
co-authored by DJCheesusReal Truman Gao
parent ef4044534f
commit 734720e11e
353 changed files with 24745 additions and 9771 deletions
@@ -4,13 +4,17 @@ use serde::{Deserialize, Serialize};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
pack::{
self,
import::{self, copy_dotminecraft},
import::{self, finish_import},
install_from::CreatePackDescription,
},
prelude::ModLoader,
state::{LinkedData, ProfileInstallStage},
state::{
AppliedContentSetPatch, EditInstance, InstanceInstallStage,
InstanceLink,
},
util::io,
};
@@ -123,7 +127,9 @@ pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool {
pub async fn import_atlauncher(
atlauncher_base_path: PathBuf, // path to base atlauncher folder
instance_folder: String, // instance folder in atlauncher_base_path
profile_path: &str, // path to profile
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
) -> crate::Result<()> {
let atlauncher_instance_path = atlauncher_base_path
.join("instances")
@@ -164,30 +170,34 @@ pub async fn import_atlauncher(
override_title: Some(atinstance.launcher.name.clone()),
project_id: None,
version_id: None,
existing_loading_bar: None,
profile_path: profile_path.to_string(),
instance_id: instance_id.to_string(),
source_filename: None,
};
let backup_name = format!("ATLauncher-{instance_folder}");
let minecraft_folder = atlauncher_instance_path;
import_atlauncher_unmanaged(
profile_path,
instance_id,
minecraft_folder,
backup_name,
description,
atinstance,
reporter,
details,
)
.await?;
Ok(())
}
async fn import_atlauncher_unmanaged(
profile_path: &str,
instance_id: &str,
minecraft_folder: PathBuf,
backup_name: String,
description: CreatePackDescription,
atinstance: ATInstance,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
) -> crate::Result<()> {
let mod_loader = format!(
"\"{}\"",
@@ -213,53 +223,53 @@ async fn import_atlauncher_unmanaged(
None
};
// Set profile data to created default profile
crate::api::profile::edit(profile_path, |prof| {
prof.name = description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string());
prof.install_stage = ProfileInstallStage::PackInstalling;
if let Some(ref project_id) = description.project_id
&& let Some(ref version_id) = description.version_id
{
prof.linked_data = Some(LinkedData {
let link = match (&description.project_id, &description.version_id) {
(Some(project_id), Some(version_id)) => {
Some(InstanceLink::ModrinthModpack {
project_id: project_id.clone(),
version_id: version_id.clone(),
locked: true,
})
}
prof.icon_path = description
.icon
.clone()
.map(|x| x.to_string_lossy().to_string());
prof.game_version.clone_from(&game_version);
prof.loader_version = loader_version.clone().map(|x| x.id);
prof.loader = mod_loader;
async { Ok(()) }
})
_ => None,
};
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
description
.icon
.clone()
.map(|x| x.to_string_lossy().to_string()),
),
link,
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
let loading_bar = copy_dotminecraft(
profile_path,
finish_import(
instance_id,
minecraft_folder,
&state.io_semaphore,
None,
reporter,
details,
)
.await?;
if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
}
Ok(())
}
@@ -4,15 +4,16 @@ use serde::{Deserialize, Serialize};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
prelude::ModLoader,
state::ProfileInstallStage,
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
util::{
fetch::{fetch, write_cached_icon},
io,
},
};
use super::{copy_dotminecraft, recache_icon};
use super::{finish_import, recache_icon};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
@@ -49,7 +50,9 @@ pub async fn is_valid_curseforge(instance_folder: PathBuf) -> bool {
pub async fn import_curseforge(
curseforge_instance_folder: PathBuf, // instance's folder
profile_path: &str, // path to profile
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
) -> crate::Result<()> {
// Load minecraftinstance.json
let minecraft_instance = serde_json::from_str::<MinecraftInstance>(
@@ -134,57 +137,64 @@ pub async fn import_curseforge(
None
};
// Set profile data to created default profile
crate::api::profile::edit(profile_path, |prof| {
prof.name = override_title
.clone()
.unwrap_or_else(|| backup_name.to_string());
prof.install_stage = ProfileInstallStage::PackInstalling;
prof.icon_path =
icon.clone().map(|x| x.to_string_lossy().to_string());
prof.game_version.clone_from(&game_version);
prof.loader_version = loader_version.clone().map(|x| x.id);
prof.loader = mod_loader;
async { Ok(()) }
})
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
} else {
// create a vanilla profile
crate::api::profile::edit(profile_path, |prof| {
prof.name = override_title
.clone()
.unwrap_or_else(|| backup_name.to_string());
prof.icon_path =
icon.clone().map(|x| x.to_string_lossy().to_string());
prof.game_version
.clone_from(&minecraft_instance.game_version);
prof.loader_version = None;
prof.loader = ModLoader::Vanilla;
async { Ok(()) }
})
crate::api::instance::edit(
instance_id,
EditInstance {
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(minecraft_instance.game_version.clone()),
protocol_version: Some(None),
loader: Some(ModLoader::Vanilla),
loader_version: Some(None),
}),
..EditInstance::default()
},
)
.await?;
}
// Copy in contained folders as overrides
let state = State::get().await?;
let loading_bar = copy_dotminecraft(
profile_path,
finish_import(
instance_id,
curseforge_instance_folder,
&state.io_semaphore,
None,
reporter,
details,
)
.await?;
if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
}
Ok(())
}
@@ -2,9 +2,15 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{State, prelude::ModLoader, state::ProfileInstallStage, util::io};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
prelude::ModLoader,
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
util::io,
};
use super::{copy_dotminecraft, recache_icon};
use super::{finish_import, recache_icon};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -36,7 +42,9 @@ pub async fn is_valid_gdlauncher(instance_folder: PathBuf) -> bool {
pub async fn import_gdlauncher(
gdlauncher_instance_folder: PathBuf, // instance's folder
profile_path: &str, // path to profile
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
) -> crate::Result<()> {
// Load config.json
let config = serde_json::from_str::<GDLauncherConfig>(
@@ -81,39 +89,40 @@ pub async fn import_gdlauncher(
None
};
// Set profile data to created default profile
crate::api::profile::edit(profile_path, |prof| {
prof.name = override_title
.clone()
.unwrap_or_else(|| backup_name.to_string());
prof.install_stage = ProfileInstallStage::PackInstalling;
prof.icon_path = icon.clone().map(|x| x.to_string_lossy().to_string());
prof.game_version.clone_from(&game_version);
prof.loader_version = loader_version.clone().map(|x| x.id);
prof.loader = mod_loader;
async { Ok(()) }
})
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
// Copy in contained folders as overrides
let state = State::get().await?;
let loading_bar = copy_dotminecraft(
profile_path,
finish_import(
instance_id,
gdlauncher_instance_folder,
&state.io_semaphore,
None,
reporter,
details,
)
.await?;
if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
}
Ok(())
}
+23 -24
View File
@@ -4,8 +4,9 @@ use serde::{Deserialize, Serialize, de};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
pack::{
import::{self, copy_dotminecraft},
import::{self, finish_import},
install_from::{self, CreatePackDescription, PackDependency},
},
util::io,
@@ -178,7 +179,9 @@ async fn load_instance_cfg(file_path: &Path) -> crate::Result<MMCInstance> {
pub async fn import_mmc(
mmc_base_path: PathBuf, // path to base mmc folder
instance_folder: String, // instance folder in mmc_base_path
profile_path: &str, // path to profile
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
) -> crate::Result<()> {
let mmc_instance_path =
mmc_base_path.join("instances").join(instance_folder);
@@ -208,8 +211,8 @@ pub async fn import_mmc(
override_title: instance_cfg.name,
project_id: None,
version_id: None,
existing_loading_bar: None,
profile_path: profile_path.to_string(),
instance_id: instance_id.to_string(),
source_filename: None,
};
let mut minecraft_folder = mmc_instance_path.join("minecraft");
@@ -232,28 +235,30 @@ pub async fn import_mmc(
// Modrinth Managed Pack
// Kept separate as we may in the future want to add special handling for modrinth managed packs
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack).await?;
import_mmc_unmanaged(instance_id, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack, reporter, details).await?;
}
Some(MMCManagedPackType::Flame | MMCManagedPackType::ATLauncher) => {
// For flame/atlauncher managed packs
// Treat as unmanaged, but with 'minecraft' folder instead of '.minecraft'
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack).await?;
import_mmc_unmanaged(instance_id, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack, reporter, details).await?;
},
Some(_) => {
// For managed packs that aren't modrinth, flame, atlauncher
// Treat as unmanaged
import_mmc_unmanaged(profile_path, minecraft_folder, "ImportedModpack".to_string(), description, mmc_pack).await?;
import_mmc_unmanaged(instance_id, minecraft_folder, "ImportedModpack".to_string(), description, mmc_pack, reporter, details).await?;
},
_ => return Err(crate::ErrorKind::InputError("Instance is managed, but managed pack type not specified in instance.cfg".to_string()).into())
}
} else {
// Directly import unmanaged pack
import_mmc_unmanaged(
profile_path,
instance_id,
minecraft_folder,
"Imported Modpack".to_string(),
description,
mmc_pack,
reporter,
details,
)
.await?;
}
@@ -261,11 +266,13 @@ pub async fn import_mmc(
}
async fn import_mmc_unmanaged(
profile_path: &str,
instance_id: &str,
minecraft_folder: PathBuf,
backup_name: String,
description: CreatePackDescription,
mmc_pack: MMCPack,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
) -> crate::Result<()> {
// Pack dependencies stored in mmc-pack.json, we convert to .mrpack pack dependencies
let dependencies = mmc_pack
@@ -307,11 +314,11 @@ async fn import_mmc_unmanaged(
})
.collect();
// Sets profile information to be that loaded from mmc-pack.json and instance.cfg
install_from::set_profile_information(
profile_path.to_string(),
install_from::set_instance_information(
instance_id.to_string(),
&description,
&backup_name,
None,
&dependencies,
false,
)
@@ -319,21 +326,13 @@ async fn import_mmc_unmanaged(
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
let loading_bar = copy_dotminecraft(
profile_path,
finish_import(
instance_id,
minecraft_folder,
&state.io_semaphore,
None,
reporter,
details,
)
.await?;
if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
}
Ok(())
}
+91 -43
View File
@@ -7,9 +7,9 @@ use io::IOError;
use serde::{Deserialize, Serialize};
use crate::{
event::{
LoadingBarId,
emit::{emit_loading, init_or_edit_loading},
install::{
InstallPhaseDetails, InstallPhaseId, InstallProgress,
InstallProgressReporter,
},
util::{
fetch::{self, IoSemaphore},
@@ -114,23 +114,43 @@ pub async fn get_importable_instances(
Ok(instances)
}
// Import an instance from a launcher type and base path
// Note: this *deletes* the submitted empty profile
// #[tracing::instrument]
pub async fn import_instance(
profile_path: &str, // This should be a blank profile
pub(crate) async fn import_instance_with_reporter(
instance_id: &str,
launcher_type: ImportLauncherType,
base_path: PathBuf,
instance_folder: String,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
import_instance_inner(
instance_id,
launcher_type,
base_path,
instance_folder,
reporter,
)
.await
}
async fn import_instance_inner(
instance_id: &str,
launcher_type: ImportLauncherType,
base_path: PathBuf,
instance_folder: String,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
tracing::debug!("Importing instance from {instance_folder}");
let details = InstallPhaseDetails::Import {
launcher_type,
instance_folder: instance_folder.clone(),
};
let res = match launcher_type {
ImportLauncherType::MultiMC | ImportLauncherType::PrismLauncher => {
mmc::import_mmc(
base_path, // path to base mmc folder
instance_folder, // instance folder in mmc_base_path
profile_path, // path to profile
instance_id,
reporter.clone(),
details.clone(),
)
.await
}
@@ -138,21 +158,27 @@ pub async fn import_instance(
atlauncher::import_atlauncher(
base_path, // path to atlauncher folder
instance_folder, // instance folder in atlauncher
profile_path, // path to profile
instance_id,
reporter.clone(),
details.clone(),
)
.await
}
ImportLauncherType::GDLauncher => {
gdlauncher::import_gdlauncher(
base_path.join("instances").join(instance_folder), // path to gdlauncher folder
profile_path, // path to profile
instance_id,
reporter.clone(),
details.clone(),
)
.await
}
ImportLauncherType::Curseforge => {
curseforge::import_curseforge(
base_path.join("Instances").join(instance_folder), // path to curseforge folder
profile_path, // path to profile
instance_id,
reporter.clone(),
details.clone(),
)
.await
}
@@ -172,11 +198,12 @@ pub async fn import_instance(
&& instances.contains(&instance_folder)
{
matched = true;
Box::pin(import_instance(
profile_path,
Box::pin(import_instance_inner(
instance_id,
lt,
base_path,
instance_folder,
reporter.clone(),
))
.await?;
break;
@@ -198,7 +225,7 @@ pub async fn import_instance(
Ok(_) => {}
Err(e) => {
tracing::warn!("Import failed: {:?}", e);
let _ = crate::api::profile::remove(profile_path).await;
let _ = crate::api::instance::remove(instance_id).await;
return Err(e);
}
}
@@ -339,33 +366,19 @@ pub async fn recache_icon(
}
}
pub async fn copy_dotminecraft(
profile_path_id: &str,
pub(crate) async fn copy_dotminecraft_with_reporter(
instance_id: &str,
dotminecraft: PathBuf,
io_semaphore: &IoSemaphore,
existing_loading_bar: Option<LoadingBarId>,
) -> crate::Result<LoadingBarId> {
// Get full path to profile
let profile_path =
crate::api::profile::get_full_path(profile_path_id).await?;
// Gets all subfiles recursively in src
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
) -> crate::Result<()> {
let instance_path =
crate::api::instance::get_full_path(instance_id).await?;
let subfiles = get_all_subfiles(&dotminecraft, false).await?;
let total_subfiles = subfiles.len() as u64;
let loading_bar = init_or_edit_loading(
existing_loading_bar,
crate::LoadingBarType::CopyProfile {
import_location: dotminecraft.clone(),
profile_name: profile_path_id.to_string(),
},
total_subfiles as f64,
"Copying files in profile",
)
.await?;
// Copy each file
for src_child in subfiles {
for (index, src_child) in subfiles.into_iter().enumerate() {
let dst_child =
src_child.strip_prefix(&dotminecraft).map_err(|_| {
crate::ErrorKind::InputError(format!(
@@ -373,16 +386,51 @@ pub async fn copy_dotminecraft(
&src_child.display()
))
})?;
let dst_child = profile_path.join(dst_child);
let dst_child = instance_path.join(dst_child);
// sleep for cpu for 1 millisecond
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
fetch::copy(&src_child, &dst_child, io_semaphore).await?;
emit_loading(&loading_bar, 1.0, None)?;
reporter
.update(
InstallPhaseId::PreparingInstance,
Some(InstallProgress {
current: (index + 1) as u64,
total: total_subfiles,
secondary: None,
}),
details.clone(),
)
.await?;
}
Ok(loading_bar)
Ok(())
}
pub(crate) async fn finish_import(
instance_id: &str,
dotminecraft: PathBuf,
io_semaphore: &IoSemaphore,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
) -> crate::Result<()> {
copy_dotminecraft_with_reporter(
instance_id,
dotminecraft,
io_semaphore,
reporter.clone(),
details,
)
.await?;
crate::launcher::install_minecraft_for_instance_id_with_reporter(
instance_id,
false,
Some(reporter),
)
.await?;
Ok(())
}
/// Recursively get a list of all subfiles in src
+159 -113
View File
@@ -1,22 +1,25 @@
use crate::State;
use crate::api::profile;
use crate::data::ModLoader;
use crate::event::emit::{emit_loading, init_loading};
use crate::event::{LoadingBarId, LoadingBarType};
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgress,
InstallProgressReporter,
};
use crate::state::{
CacheBehaviour, CachedEntry, LinkedData, Profile, ProfileInstallStage,
SideType,
AppliedContentSetPatch, CacheBehaviour, CachedEntry, ContentSourceKind,
EditInstance, InstanceInstallStage, InstanceLink, SideType,
};
use crate::util::fetch::{
DownloadMeta, DownloadReason, fetch, fetch_advanced, sha1_file_async,
write_cached_icon,
DownloadMeta, DownloadReason, FetchProgressFn, fetch,
fetch_advanced_with_progress, sha1_file_async, write_cached_icon,
};
use path_util::SafeRelativeUtf8UnixPathBuf;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -102,30 +105,30 @@ pub enum CreatePackLocation {
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePackProfile {
pub name: String, // the name of the profile, and relative path
pub game_version: String, // the game version of the profile
pub struct CreatePackInstance {
pub name: String, // the name of the instance and relative path
pub game_version: String, // the game version of the instance
pub modloader: ModLoader, // the modloader to use
pub loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader. defaults to latest
pub icon: Option<PathBuf>, // the icon for the profile
pub icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
pub linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
pub icon: Option<PathBuf>, // the icon for the instance
pub icon_url: Option<String>, // the URL icon for an instance during import
pub link: Option<InstanceLink>,
pub unknown_file: bool, // true when pack file isn't found on Modrinth via hash lookup
pub skip_install_profile: Option<bool>,
pub no_watch: Option<bool>,
}
// default
impl Default for CreatePackProfile {
impl Default for CreatePackInstance {
fn default() -> Self {
CreatePackProfile {
CreatePackInstance {
name: "Untitled".to_string(),
game_version: "1.19.4".to_string(),
modloader: ModLoader::Vanilla,
loader_version: None,
icon: None,
icon_url: None,
linked_data: None,
link: None,
unknown_file: false,
skip_install_profile: Some(true),
no_watch: Some(false),
@@ -155,26 +158,25 @@ pub struct CreatePackDescription {
pub override_title: Option<String>,
pub project_id: Option<String>,
pub version_id: Option<String>,
pub existing_loading_bar: Option<LoadingBarId>,
pub profile_path: String,
pub instance_id: String,
pub source_filename: Option<String>,
}
pub async fn get_profile_from_pack(
pub async fn get_instance_from_pack(
location: CreatePackLocation,
) -> crate::Result<CreatePackProfile> {
) -> crate::Result<CreatePackInstance> {
match location {
CreatePackLocation::FromVersionId {
project_id,
version_id,
title,
icon_url,
} => Ok(CreatePackProfile {
} => Ok(CreatePackInstance {
name: title,
icon_url,
linked_data: Some(LinkedData {
link: Some(InstanceLink::ModrinthModpack {
project_id,
version_id,
locked: true,
}),
..Default::default()
}),
@@ -212,7 +214,7 @@ pub async fn get_profile_from_pack(
false
};
Ok(CreatePackProfile {
Ok(CreatePackInstance {
name: file_name,
unknown_file: !is_known_file,
..Default::default()
@@ -221,39 +223,20 @@ pub async fn get_profile_from_pack(
}
}
#[tracing::instrument]
pub async fn generate_pack_from_version_id(
#[tracing::instrument(skip(reporter))]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn generate_pack_from_version_id_with_reporter(
project_id: String,
version_id: String,
title: String,
icon_url: Option<String>,
profile_path: String,
initialized_loading_bar: Option<LoadingBarId>,
instance_id: String,
reason: DownloadReason,
reporter: InstallProgressReporter,
) -> crate::Result<CreatePack> {
let state = State::get().await?;
let has_icon_url = icon_url.is_some();
let loading_bar = if let Some(bar) = initialized_loading_bar {
emit_loading(&bar, 0.0, Some("Downloading pack file"))?;
bar
} else {
init_loading(
LoadingBarType::PackFileDownload {
profile_path: profile_path.clone(),
pack_name: title.clone(),
icon: icon_url,
pack_version: version_id.clone(),
},
100.0,
"Downloading pack file",
)
.await?
};
emit_loading(&loading_bar, 0.0, Some("Fetching version"))?;
let version = CachedEntry::get_version(
&version_id,
Some(CacheBehaviour::Bypass),
@@ -266,9 +249,8 @@ pub async fn generate_pack_from_version_id(
"Invalid version ID specified!".to_string(),
)
})?;
emit_loading(&loading_bar, 10.0, None)?;
// Update profile with correct loader and game version from the API version metadata,
// Update instance with correct loader and game version from the API version metadata,
// so the UI shows accurate info while the pack file is still downloading.
if let Some(game_version) = version.game_versions.first() {
let loader = version
@@ -277,12 +259,19 @@ pub async fn generate_pack_from_version_id(
.map(|l| ModLoader::from_string(l))
.unwrap_or(ModLoader::Vanilla);
let game_version = game_version.clone();
let profile_path_clone = profile_path.clone();
profile::edit(&profile_path_clone, |prof| {
prof.game_version.clone_from(&game_version);
prof.loader = loader;
async { Ok(()) }
})
crate::api::instance::edit(
&instance_id,
EditInstance {
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version),
protocol_version: Some(None),
loader: Some(loader),
loader_version: None,
}),
..EditInstance::default()
},
)
.await?;
}
@@ -301,37 +290,73 @@ pub async fn generate_pack_from_version_id(
)
})?;
let profile =
Profile::get(&profile_path, &state.pool)
let metadata =
crate::api::instance::get(&instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(
profile_path.to_string(),
)
.as_error()
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let download_meta = DownloadMeta {
reason,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
game_version: metadata.applied_content_set.game_version.clone(),
loader: metadata.applied_content_set.loader.as_str().to_string(),
dependent_on: Some(version_id.clone()),
};
let file = fetch_advanced(
let details = InstallPhaseDetails::Modpack {
project_id: Some(project_id.clone()),
version_id: Some(version_id.clone()),
title: Some(title.clone()),
};
let mut last_reported_bytes = 0_u64;
let mut progress =
|current: u64,
total: u64|
-> Pin<Box<dyn Future<Output = crate::Result<()>> + Send>> {
let min_delta = (total / 200).max(256 * 1024);
if current < total
&& current.saturating_sub(last_reported_bytes) < min_delta
{
return Box::pin(async { Ok(()) });
}
last_reported_bytes = current;
let reporter = reporter.clone();
let details = details.clone();
Box::pin(async move {
reporter
.update(
InstallPhaseId::DownloadingPackFile,
Some(InstallProgress {
current,
total,
secondary: None,
}),
details,
)
.await?;
Ok(())
})
};
let progress = Some(&mut progress as &mut FetchProgressFn<'_>);
let file = fetch_advanced_with_progress(
Method::GET,
&url,
hash.map(|x| &**x),
None,
None,
Some(&download_meta),
Some((&loading_bar, 70.0)),
None,
None,
&state.fetch_semaphore,
&state.pool,
progress,
)
.await?;
emit_loading(&loading_bar, 0.0, Some("Fetching project metadata"))?;
let project = CachedEntry::get_project(
&version.project_id,
@@ -350,8 +375,7 @@ pub async fn generate_pack_from_version_id(
// When installing to an existing profile (e.g. server projects),
// icon_url is None and we preserve the profile's existing icon.
let icon = if has_icon_url {
emit_loading(&loading_bar, 10.0, Some("Retrieving icon"))?;
let fetched = if let Some(icon_url) = project.icon_url {
if let Some(icon_url) = project.icon_url {
let state = State::get().await?;
let icon_bytes = fetch(
&icon_url,
@@ -380,18 +404,18 @@ pub async fn generate_pack_from_version_id(
}
} else {
None
};
emit_loading(&loading_bar, 10.0, None)?;
fetched
}
} else {
emit_loading(&loading_bar, 20.0, None)?;
None
};
// Set the icon immediately so the UI shows it during download.
if let Some(ref icon_path) = icon {
let _ =
profile::edit_icon(&profile_path, Some(icon_path.as_path())).await;
let _ = crate::api::instance::edit_icon(
&instance_id,
Some(icon_path.as_path()),
)
.await;
}
Ok(CreatePack {
@@ -401,8 +425,8 @@ pub async fn generate_pack_from_version_id(
override_title: Some(title),
project_id: Some(project_id),
version_id: Some(version_id),
existing_loading_bar: Some(loading_bar),
profile_path,
instance_id,
source_filename: None,
},
})
}
@@ -411,8 +435,11 @@ pub async fn generate_pack_from_version_id(
pub async fn generate_pack_from_file(
path: PathBuf,
profile_path: String,
instance_id: String,
) -> crate::Result<CreatePack> {
let source_filename =
path.file_name().map(|x| x.to_string_lossy().to_string());
Ok(CreatePack {
file: CreatePackFile::Path(path),
description: CreatePackDescription {
@@ -420,20 +447,21 @@ pub async fn generate_pack_from_file(
override_title: None,
project_id: None,
version_id: None,
existing_loading_bar: None,
profile_path,
instance_id,
source_filename,
},
})
}
/// Sets generated profile attributes to the pack ones (using profile::edit)
/// Sets generated instance attributes to the pack ones.
/// This includes the pack name, icon, game version, loader version, and loader
pub async fn set_profile_information(
profile_path: String,
pub async fn set_instance_information(
instance_id: String,
description: &CreatePackDescription,
backup_name: &str,
pack_version_id: Option<&str>,
dependencies: &HashMap<PackDependency, String>,
ignore_lock: bool, // do not change locked status
_ignore_lock: bool,
) -> crate::Result<()> {
let mut game_version: Option<&String> = None;
let mut mod_loader = None;
@@ -479,40 +507,58 @@ pub async fn set_profile_information(
} else {
None
};
// Sets values in profile
crate::api::profile::edit(&profile_path, |prof| {
prof.name = description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string());
prof.install_stage = ProfileInstallStage::PackInstalling;
if let Some(ref project_id) = description.project_id
&& let Some(ref version_id) = description.version_id
{
prof.linked_data = Some(LinkedData {
let link = match (&description.project_id, &description.version_id) {
(Some(project_id), Some(version_id)) => {
Some(InstanceLink::ModrinthModpack {
project_id: project_id.clone(),
version_id: version_id.clone(),
locked: if !ignore_lock {
true
} else {
prof.linked_data.as_ref().is_none_or(|x| x.locked)
},
})
}
// Only update the icon if the pack provides one.
// When installing to an existing profile, icon is None
// and we preserve the profile's existing icon.
if let Some(ref icon) = description.icon {
prof.icon_path = Some(icon.to_string_lossy().to_string());
_ if description.source_filename.is_some() => {
Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(backup_name.to_string()),
version_number: pack_version_id.map(ToString::to_string),
filename: description.source_filename.clone(),
})
}
prof.game_version.clone_from(game_version);
prof.loader_version = loader_version.clone().map(|x| x.id);
prof.loader = mod_loader;
async { Ok(()) }
})
_ => None,
};
let source_kind = match &link {
Some(InstanceLink::ModrinthModpack { .. }) => {
Some(ContentSourceKind::ModrinthModpack)
}
Some(InstanceLink::ImportedModpack { .. }) => {
Some(ContentSourceKind::ImportedModpack)
}
_ => None,
};
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: description
.icon
.as_ref()
.map(|icon| Some(icon.to_string_lossy().to_string())),
link,
content_set_patch: Some(AppliedContentSetPatch {
source_kind,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
Ok(())
}
+336 -164
View File
@@ -1,32 +1,40 @@
use crate::event::LoadingBarType;
use crate::event::emit::{
emit_loading, init_or_edit_loading, loading_try_for_each_concurrent,
use crate::State;
use crate::event::emit::loading_try_for_each_concurrent;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgress,
InstallProgressReporter, InstallProgressSecondary,
};
use crate::pack::install_from::{
EnvType, PackFile, PackFileHash, set_profile_information,
EnvType, PackFile, PackFileHash, set_instance_information,
};
use crate::state::instances::ContentSourceKind;
use crate::state::{
CacheBehaviour, CachedEntry, Profile, ProfileInstallStage, SideType,
cache_file_hash,
CachedEntry, EditInstance, InstanceInstallStage, SideType, cache_file_hash,
};
use crate::util::fetch::{DownloadMeta, DownloadReason, fetch_mirrors, write};
use crate::util::io;
use crate::{State, profile};
use async_zip::base::read::seek::ZipFileReader as SeekZipFileReader;
use async_zip::base::read::{WithEntry, ZipEntryReader};
use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader;
use futures::StreamExt;
use path_util::SafeRelativeUtf8UnixPathBuf;
use super::install_from::{
CreatePack, CreatePackFile, CreatePackLocation, PackFormat,
generate_pack_from_file, generate_pack_from_version_id,
use std::future::Future;
use std::pin::Pin;
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use super::install_from::{CreatePack, CreatePackFile, PackFormat};
use crate::data::ProjectType;
use std::io::{Cursor, ErrorKind};
use std::path::Path;
use tokio::io::AsyncWriteExt;
type ExtractProgressFn<'a> = dyn FnMut(u64) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + 'a>>
+ Send
+ 'a;
enum MrpackZipReader {
Memory(async_zip::tokio::read::seek::ZipFileReader<Cursor<bytes::Bytes>>),
// Local imports stay on disk so large .mrpacks do not have to fit in memory.
@@ -100,6 +108,7 @@ impl MrpackZipReader {
index: usize,
path: &Path,
semaphore: &crate::util::fetch::IoSemaphore,
progress: Option<&mut ExtractProgressFn<'_>>,
) -> crate::Result<(u64, String)> {
match self {
Self::Memory(reader) => {
@@ -107,6 +116,7 @@ impl MrpackZipReader {
reader.reader_with_entry(index).await?,
path,
semaphore,
progress,
)
.await
}
@@ -115,6 +125,7 @@ impl MrpackZipReader {
reader.reader_with_entry(index).await?,
path,
semaphore,
progress,
)
.await
}
@@ -156,6 +167,7 @@ async fn extract_zip_entry<R>(
mut reader: ZipEntryReader<'_, R, WithEntry<'_>>,
path: &Path,
semaphore: &crate::util::fetch::IoSemaphore,
mut progress: Option<&mut ExtractProgressFn<'_>>,
) -> crate::Result<(u64, String)>
where
R: futures_lite::io::AsyncBufRead + Unpin,
@@ -197,6 +209,9 @@ where
.map_err(|e| io::IOError::with_path(e, &temp_path))?;
hasher.update(&buffer[..bytes_read]);
size += bytes_read as u64;
if let Some(progress) = progress.as_mut() {
progress(bytes_read as u64).await?;
}
}
file.flush()
@@ -216,75 +231,37 @@ where
Ok((size, hasher.digest().to_string()))
}
/// Install a pack
/// Wrapper around install_pack_files that generates a pack creation description, and
/// attempts to install the pack files. If it fails, it will remove the profile (fail safely)
/// Install a modpack from a mrpack file (a modrinth .zip format)
pub async fn install_zipped_mrpack(
location: CreatePackLocation,
profile_path: String,
) -> crate::Result<String> {
// Get file from description
let create_pack: CreatePack = match location {
CreatePackLocation::FromVersionId {
project_id,
version_id,
title,
icon_url,
} => {
generate_pack_from_version_id(
project_id,
version_id,
title,
icon_url,
profile_path.clone(),
None,
DownloadReason::Modpack,
)
.await?
}
CreatePackLocation::FromFile { path } => {
generate_pack_from_file(path, profile_path.clone()).await?
}
};
// Install pack files, and if it fails, fail safely by removing the profile
let result = install_zipped_mrpack_files(
create_pack,
false,
DownloadReason::Modpack,
)
.await;
match result {
Ok(profile) => Ok(profile),
Err(err) => {
let _ = crate::api::profile::remove(&profile_path).await;
Err(err)
}
}
}
/// Install all pack files from a description
/// Does not remove the profile if it fails
pub async fn install_zipped_mrpack_files(
pub(crate) async fn install_zipped_mrpack_files_with_reporter(
create_pack: CreatePack,
ignore_lock: bool,
reason: DownloadReason,
reporter: InstallProgressReporter,
) -> crate::Result<String> {
let state = &State::get().await?;
let file = create_pack.file;
let description = create_pack.description.clone(); // make a copy for profile edit function
let description = create_pack.description.clone();
let icon = create_pack.description.icon;
let project_id = create_pack.description.project_id;
let version_id = create_pack.description.version_id;
let existing_loading_bar = create_pack.description.existing_loading_bar;
let profile_path = create_pack.description.profile_path;
let icon_exists = icon.is_some();
let instance_id = create_pack.description.instance_id;
let mut icon_exists = icon.is_some();
let mut zip_reader = MrpackZipReader::new(&file).await?;
let instance_full_path =
crate::api::instance::get_full_path(&instance_id).await?;
let modpack_details = InstallPhaseDetails::Modpack {
project_id: project_id.clone(),
version_id: version_id.clone(),
title: description.override_title.clone(),
};
reporter
.update(
InstallPhaseId::ReadingPackManifest,
None,
modpack_details.clone(),
)
.await?;
// Extract index of modrinth.index.json
let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| {
@@ -299,7 +276,6 @@ pub async fn install_zipped_mrpack_files(
manifest.push_str(&zip_reader.read_entry_to_string(manifest_idx).await?);
let pack: PackFormat = serde_json::from_str(&manifest)?;
if &*pack.game != "minecraft" {
return Err(crate::ErrorKind::InputError(
"Pack does not support Minecraft".to_string(),
@@ -307,6 +283,40 @@ pub async fn install_zipped_mrpack_files(
.into());
}
reporter
.update(InstallPhaseId::ResolvingPack, None, modpack_details.clone())
.await?;
if !icon_exists {
let icon_entry =
zip_reader.file().entries().iter().enumerate().find_map(
|(index, entry)| {
matches!(
entry.filename().as_str(),
Ok("icon.png"
| "overrides/icon.png"
| "client-overrides/icon.png")
)
.then_some(index)
},
);
if let Some(icon_entry) = icon_entry {
let icon_path = instance_full_path.join("icon.png");
zip_reader
.extract_entry(
icon_entry,
&icon_path,
&state.io_semaphore,
None,
)
.await?;
crate::api::instance::edit_icon(&instance_id, Some(&icon_path))
.await?;
icon_exists = true;
}
}
// Cache the modpack file hashes for later filtering of user-added content
// Includes both manifest file hashes and computed hashes for override files
if let Some(ref version_id) = version_id {
@@ -370,66 +380,113 @@ pub async fn install_zipped_mrpack_files(
);
}
// Sets generated profile attributes to the pack ones (using profile::edit)
set_profile_information(
profile_path.clone(),
set_instance_information(
instance_id.clone(),
&description,
&pack.name,
Some(&pack.version_id),
&pack.dependencies,
ignore_lock,
)
.await?;
let profile_path = profile_path.clone();
let loading_bar = init_or_edit_loading(
existing_loading_bar,
LoadingBarType::PackDownload {
profile_path: profile_path.clone(),
pack_name: pack.name.clone(),
icon,
pack_id: project_id.clone(),
pack_version: version_id.clone(),
},
100.0,
"Downloading modpack",
)
.await?;
let profile =
Profile::get(&profile_path, &state.pool)
let metadata =
crate::api::instance::get(&instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(
profile_path.to_string(),
)
.as_error()
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let instance_path = metadata.instance.path.clone();
let download_meta = DownloadMeta {
reason,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
game_version: metadata.applied_content_set.game_version.clone(),
loader: metadata.applied_content_set.loader.as_str().to_string(),
dependent_on: version_id.clone(),
};
let num_files = pack.files.len();
let content_total_bytes = pack
.files
.iter()
.map(|file| file.file_size as u64)
.sum::<u64>();
reporter
.update(
InstallPhaseId::DownloadingContent,
Some(InstallProgress {
current: 0,
total: num_files as u64,
secondary: (content_total_bytes > 0).then_some(
InstallProgressSecondary {
current: 0,
total: content_total_bytes,
},
),
}),
modpack_details.clone(),
)
.await?;
let content_progress = Arc::new(AtomicU64::new(0));
let content_bytes_progress = Arc::new(AtomicU64::new(0));
loading_try_for_each_concurrent(
futures::stream::iter(pack.files).map(Ok::<PackFile, crate::Error>),
None,
Some(&loading_bar),
None,
70.0,
num_files,
None,
|project| {
let profile_path = profile_path.clone();
let instance_id = instance_id.clone();
let instance_path = instance_path.clone();
let instance_full_path = instance_full_path.clone();
let download_meta = download_meta.clone();
let pack_version_id = version_id.clone();
let reporter = reporter.clone();
let modpack_details = modpack_details.clone();
let content_progress = content_progress.clone();
let content_bytes_progress = content_bytes_progress.clone();
async move {
let mark_downloaded = |file_size: u64| {
let reporter = reporter.clone();
let modpack_details = modpack_details.clone();
let content_progress = content_progress.clone();
let content_bytes_progress = content_bytes_progress.clone();
async move {
let current = content_progress
.fetch_add(1, Ordering::Relaxed)
+ 1;
let current_bytes = content_bytes_progress
.fetch_add(file_size, Ordering::Relaxed)
+ file_size;
reporter
.update(
InstallPhaseId::DownloadingContent,
Some(InstallProgress {
current,
total: num_files as u64,
secondary: (content_total_bytes > 0)
.then_some(InstallProgressSecondary {
current: current_bytes
.min(content_total_bytes),
total: content_total_bytes,
}),
}),
modpack_details,
)
.await?;
Ok::<(), crate::Error>(())
}
};
//TODO: Future update: prompt user for optional files in a modpack
if let Some(env) = project.env
&& env
.get(&EnvType::Client)
.is_some_and(|x| x == &SideType::Unsupported)
{
mark_downloaded(project.file_size as u64).await?;
return Ok(());
}
@@ -447,13 +504,11 @@ pub async fn install_zipped_mrpack_files(
)
.await?;
let path = profile::get_full_path(&profile_path)
.await?
.join(project.path.as_str());
let path = instance_full_path.join(project.path.as_str());
cache_file_hash(
file.clone(),
&profile_path,
&instance_path,
project.path.as_str(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
ProjectType::get_from_parent_folder(&path),
@@ -464,14 +519,51 @@ pub async fn install_zipped_mrpack_files(
write(&path, &file, &state.io_semaphore).await?;
if let Some(project_type) =
ProjectType::get_from_parent_folder(project.path.as_str())
{
let hash =
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x);
let file_info = if let Some(hash) = hash {
CachedEntry::get_file_many(
&[hash],
None,
&state.pool,
&state.api_semaphore,
)
.await?
.into_iter()
.next()
} else {
None
};
if let Some(hash) = hash {
crate::state::instances::commands::record_project_file(
&instance_id,
project.path.as_str(),
hash,
project.file_size as u64,
project_type,
modpack_source_kind(pack_version_id.as_deref()),
file_info
.as_ref()
.map(|file| file.project_id.as_str()),
file_info
.as_ref()
.map(|file| file.version_id.as_str()),
state,
)
.await?;
}
}
mark_downloaded(project.file_size as u64).await?;
Ok(())
}
},
)
.await?;
emit_loading(&loading_bar, 0.0, Some("Extracting overrides"))?;
let override_file_entries = zip_reader
.file()
.entries()
@@ -485,9 +577,60 @@ pub async fn install_zipped_mrpack_files(
.then(|| (index, file.clone()))
})
.collect::<Vec<_>>();
let override_file_entries_count = override_file_entries.len();
let override_total_bytes = override_file_entries
.iter()
.map(|(_, file)| file.uncompressed_size())
.sum::<u64>();
let progress = (override_total_bytes > 0).then_some(InstallProgress {
current: 0,
total: override_total_bytes,
secondary: None,
});
reporter
.update(
InstallPhaseId::ExtractingOverrides,
progress,
modpack_details.clone(),
)
.await?;
for (i, (index, file)) in override_file_entries.into_iter().enumerate() {
let extracted_override_bytes = Arc::new(AtomicU64::new(0));
let mut last_reported_override_bytes = 0_u64;
let reporter_for_overrides = reporter.clone();
let details_for_overrides = modpack_details.clone();
let mut report_override_progress = |bytes_read: u64| -> Pin<
Box<dyn Future<Output = crate::Result<()>> + Send>,
> {
let current = extracted_override_bytes
.fetch_add(bytes_read, Ordering::Relaxed)
+ bytes_read;
let min_delta = (override_total_bytes / 200).max(256 * 1024);
if current < override_total_bytes
&& current.saturating_sub(last_reported_override_bytes) < min_delta
{
return Box::pin(async { Ok(()) });
}
last_reported_override_bytes = current;
let reporter = reporter_for_overrides.clone();
let details = details_for_overrides.clone();
Box::pin(async move {
reporter
.update(
InstallPhaseId::ExtractingOverrides,
Some(InstallProgress {
current: current.min(override_total_bytes),
total: override_total_bytes,
secondary: None,
}),
details,
)
.await?;
Ok(())
})
};
for (index, file) in override_file_entries {
let relative_override_file_path =
SafeRelativeUtf8UnixPathBuf::try_from(
file.filename().as_str().unwrap().to_string(),
@@ -501,18 +644,30 @@ pub async fn install_zipped_mrpack_files(
))
})?;
let path = profile::get_full_path(&profile_path)
.await?
.join(relative_override_file_path.as_str());
let (size, hash) = zip_reader
.extract_entry(index, &path, &state.io_semaphore)
.await?;
let path =
instance_full_path.join(relative_override_file_path.as_str());
let (size, hash) = if override_total_bytes > 0 {
let progress =
&mut report_override_progress as &mut ExtractProgressFn<'_>;
zip_reader
.extract_entry(
index,
&path,
&state.io_semaphore,
Some(progress),
)
.await?
} else {
zip_reader
.extract_entry(index, &path, &state.io_semaphore, None)
.await?
};
crate::state::cache_file_hash_metadata(
&profile_path,
&instance_path,
relative_override_file_path.as_str(),
size,
hash,
hash.clone(),
ProjectType::get_from_parent_folder(
relative_override_file_path.as_str(),
),
@@ -521,41 +676,54 @@ pub async fn install_zipped_mrpack_files(
)
.await?;
emit_loading(
&loading_bar,
30.0 / override_file_entries_count as f64,
Some(&format!(
"Extracting override {}/{override_file_entries_count}",
i + 1
)),
)?;
if let Some(project_type) = ProjectType::get_from_parent_folder(
relative_override_file_path.as_str(),
) {
crate::state::instances::commands::record_project_file(
&instance_id,
relative_override_file_path.as_str(),
&hash,
size,
project_type,
modpack_source_kind(version_id.as_deref()),
None,
None,
state,
)
.await?;
}
}
// If the icon doesn't exist, we expect icon.png to be a potential icon.
// If it doesn't exist, and an override to icon.png exists, cache and use that
let potential_icon = profile::get_full_path(&profile_path)
.await?
.join("icon.png");
let potential_icon = instance_full_path.join("icon.png");
if !icon_exists && potential_icon.exists() {
profile::edit_icon(&profile_path, Some(&potential_icon)).await?;
crate::api::instance::edit_icon(&instance_id, Some(&potential_icon))
.await?;
}
if let Some(profile_val) = profile::get(&profile_path).await? {
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
}
crate::launcher::install_minecraft_for_instance_id_with_reporter(
&instance_id,
false,
Some(reporter),
)
.await?;
Ok::<String, crate::Error>(profile_path.clone())
Ok::<String, crate::Error>(instance_id.clone())
}
fn modpack_source_kind(version_id: Option<&str>) -> ContentSourceKind {
if version_id.is_some() {
ContentSourceKind::ModrinthModpack
} else {
ContentSourceKind::ImportedModpack
}
}
#[tracing::instrument(skip(mrpack_file))]
pub async fn remove_all_related_files(
profile_path: String,
instance_id: String,
mrpack_file: CreatePackFile,
) -> crate::Result<()> {
// Updates can remove files from a locally imported or downloaded pack, so share the same reader path.
@@ -581,17 +749,29 @@ pub async fn remove_all_related_files(
.into());
}
// Set install stage to installing, and do not change it back (as files are being removed and are not being reinstalled here)
crate::api::profile::edit(&profile_path, |prof| {
prof.install_stage = ProfileInstallStage::PackInstalling;
async { Ok(()) }
})
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
..EditInstance::default()
},
)
.await?;
// First, remove all modrinth projects by their version hashes
// Remove all modrinth projects by their version hashes
// We need to do a fetch to get the project ids from Modrinth
let state = State::get().await?;
let metadata =
crate::api::instance::get(&instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let instance_full_path =
crate::api::instance::get_full_path(&instance_id).await?;
let all_hashes = pack
.files
.iter()
@@ -612,34 +792,28 @@ pub async fn remove_all_related_files(
.map(|p| p.project_id)
.collect::<Vec<_>>();
let profile = profile::get(&profile_path).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(profile_path.to_string())
})?;
let profile_full_path = profile::get_full_path(&profile_path).await?;
for (file_path, project) in profile
.get_projects(
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?
for file in crate::state::instances::commands::list_project_files(
&metadata.instance.id,
&state,
)
.await?
{
if let Some(metadata) = &project.metadata
&& to_remove.contains(&metadata.project_id)
if let Some(project_id) = &file.project_id
&& to_remove.contains(project_id)
{
match io::remove_file(profile_full_path.join(file_path)).await {
Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (),
Err(err) => return Err(err.into()),
}
crate::state::instances::commands::remove_project(
&metadata.instance.id,
&file.relative_path,
&state,
)
.await?;
}
}
// Iterate over all Modrinth project file paths in the json, and remove them
// (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized)
for file in pack.files {
match io::remove_file(profile_full_path.join(file.path.as_str())).await
match io::remove_file(instance_full_path.join(file.path.as_str())).await
{
Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (),
@@ -672,9 +846,7 @@ pub async fn remove_all_related_files(
// Remove this file if a corresponding one exists in the filesystem
match io::remove_file(
profile::get_full_path(&profile_path)
.await?
.join(relative_override_file_path.as_str()),
instance_full_path.join(relative_override_file_path.as_str()),
)
.await
{