6767 lines
228 KiB
Rust
6767 lines
228 KiB
Rust
use super::events::{InstallProgressReporter, emit_install_job};
|
|
use super::model::{
|
|
InstallCleanup, InstallContinuationState, InstallErrorContext,
|
|
InstallErrorView, InstallJavaStep, InstallJobDisplay, InstallJobEventKind,
|
|
InstallJobSnapshot, InstallJobState, InstallJobStatus, InstallPauseReason,
|
|
InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit,
|
|
InstallProgress, InstallRequest, InstallRollbackState, InstallTarget,
|
|
InstanceUpgradeCompatibilityWarning, InstanceUpgradeDisplayNames,
|
|
InstanceUpgradeExecution, InstanceUpgradeExternalChange,
|
|
InstanceUpgradeExternalChangeKind, InstanceUpgradeResult,
|
|
InstanceUpgradeWatchBaseline, SharedUpgradeMode, initial_phase_for_request,
|
|
};
|
|
use super::{diagnostics, recovery, store};
|
|
use crate::ErrorKind;
|
|
use crate::api::pack::install_from::{
|
|
CreatePackLocation, generate_pack_from_file,
|
|
generate_pack_from_version_id_with_reporter, get_instance_from_pack,
|
|
};
|
|
use crate::api::pack::install_mrpack::{
|
|
MrpackInstallOutcome, install_zipped_mrpack_files_with_reporter,
|
|
related_file_paths,
|
|
};
|
|
use crate::event::InstancePayloadType;
|
|
use crate::event::emit::emit_instance;
|
|
use crate::state::{
|
|
ContentProvider, ContentProviderRef, InstanceInstallStage, InstanceLink,
|
|
InstanceUpgradeAction, InstanceUpgradeDependencyChangeKind,
|
|
LoaderComponent, LoaderComponentKind, LoaderComponentRole, ModLoader,
|
|
State,
|
|
};
|
|
use crate::util::fetch::DownloadReason;
|
|
use futures::stream::{FuturesUnordered, StreamExt};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::future::Future;
|
|
use std::path::PathBuf;
|
|
use uuid::Uuid;
|
|
|
|
enum InstallExecutionOutcome<T> {
|
|
Completed(T),
|
|
WaitingForUser(InstallPauseReason),
|
|
}
|
|
|
|
pub async fn create_instance(
|
|
name: String,
|
|
game_version: String,
|
|
loader: ModLoader,
|
|
loader_version: Option<String>,
|
|
icon_path: Option<String>,
|
|
link: InstanceLink,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
create_instance_with_adjuncts(
|
|
name,
|
|
game_version,
|
|
loader,
|
|
loader_version,
|
|
Vec::new(),
|
|
icon_path,
|
|
link,
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn create_instance_with_adjuncts(
|
|
name: String,
|
|
game_version: String,
|
|
loader: ModLoader,
|
|
loader_version: Option<String>,
|
|
adjuncts: Vec<crate::state::LoaderComponent>,
|
|
icon_path: Option<String>,
|
|
link: InstanceLink,
|
|
game_dir_override: Option<String>,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::CreateInstance {
|
|
name,
|
|
game_version,
|
|
loader,
|
|
loader_version,
|
|
adjuncts,
|
|
icon_path,
|
|
link,
|
|
game_dir_override,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn create_modpack_instance(
|
|
location: CreatePackLocation,
|
|
post_install_edit: Option<InstallPostInstallEdit>,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::CreateModpackInstance {
|
|
location,
|
|
post_install_edit,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn import_instance(
|
|
launcher_type: crate::api::pack::import::ImportLauncherType,
|
|
base_path: PathBuf,
|
|
instance_folder: String,
|
|
symlink: bool,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::ImportInstance {
|
|
launcher_type,
|
|
base_path,
|
|
instance_folder,
|
|
instance_path: None,
|
|
symlink,
|
|
game_version: None,
|
|
loader: None,
|
|
loader_version: None,
|
|
game_dir_override: None,
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Like [`import_instance`] but with a pre-resolved filesystem path.
|
|
/// Used by the frontend when the path is already known from scanning,
|
|
/// avoiding redundant config/registry re-resolution.
|
|
pub async fn import_instance_with_path(
|
|
launcher_type: crate::api::pack::import::ImportLauncherType,
|
|
base_path: PathBuf,
|
|
instance_folder: String,
|
|
instance_path: Option<String>,
|
|
symlink: bool,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::ImportInstance {
|
|
launcher_type,
|
|
base_path,
|
|
instance_folder,
|
|
instance_path,
|
|
symlink,
|
|
game_version: None,
|
|
loader: None,
|
|
loader_version: None,
|
|
game_dir_override: None,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn import_instance_with_plan(
|
|
launcher_type: crate::api::pack::import::ImportLauncherType,
|
|
base_path: PathBuf,
|
|
instance_folder: String,
|
|
instance_path: Option<String>,
|
|
symlink: bool,
|
|
game_version: Option<String>,
|
|
loader: Option<crate::state::ModLoader>,
|
|
loader_version: Option<String>,
|
|
game_dir_override: Option<String>,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::ImportInstance {
|
|
launcher_type,
|
|
base_path,
|
|
instance_folder,
|
|
instance_path,
|
|
symlink,
|
|
game_version,
|
|
loader,
|
|
loader_version,
|
|
game_dir_override,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn duplicate_instance(
|
|
source_instance_id: String,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
// Directly associated instances own no files to copy: duplicating one
|
|
// would clone the linked launcher's `.minecraft` into Axolotl.
|
|
let state = State::get().await?;
|
|
if let Some(metadata) =
|
|
crate::state::get_instance(&source_instance_id, &state.pool).await?
|
|
&& metadata.instance.is_direct_linked()
|
|
{
|
|
return Err(crate::ErrorKind::InputError(format!(
|
|
"\"{}\" is directly associated with an external launcher and \
|
|
cannot be duplicated; its files are managed by that launcher",
|
|
metadata.instance.name
|
|
))
|
|
.into());
|
|
}
|
|
drop(state);
|
|
|
|
start(InstallRequest::DuplicateInstance { source_instance_id }).await
|
|
}
|
|
|
|
pub async fn install_existing_instance(
|
|
instance_id: String,
|
|
force: bool,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::InstallExistingInstance { instance_id, force }).await
|
|
}
|
|
|
|
pub async fn upgrade_unmanaged_instance(
|
|
instance_id: String,
|
|
plan_id: String,
|
|
execution: InstanceUpgradeExecution,
|
|
create_full_backup: bool,
|
|
shared_upgrade_mode: SharedUpgradeMode,
|
|
display_names: InstanceUpgradeDisplayNames,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::UpgradeUnmanagedInstance {
|
|
instance_id,
|
|
plan_id,
|
|
execution,
|
|
create_full_backup,
|
|
shared_upgrade_mode,
|
|
display_names,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn install_content(
|
|
instance_id: String,
|
|
project_id: String,
|
|
version_id: Option<String>,
|
|
content_type: modrinth_content_management::ContentType,
|
|
selected: modrinth_content_management::ResolutionPreferences,
|
|
excluded_project_ids: Vec<String>,
|
|
display_title: String,
|
|
display_icon: Option<String>,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::InstallContent {
|
|
instance_id,
|
|
project_id,
|
|
version_id,
|
|
content_type,
|
|
selected,
|
|
excluded_project_ids,
|
|
display_title,
|
|
display_icon,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn install_curseforge_content(
|
|
request: crate::api::curseforge::CurseForgeInstallRequest,
|
|
display_title: String,
|
|
display_icon: Option<String>,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::InstallCurseForgeContent {
|
|
request,
|
|
display_title,
|
|
display_icon,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn install_curseforge_world(
|
|
request: crate::api::curseforge::CurseForgeWorldInstallRequest,
|
|
display_title: String,
|
|
display_icon: Option<String>,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::InstallCurseForgeWorld {
|
|
request,
|
|
display_title,
|
|
display_icon,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn download_java(
|
|
vendor: String,
|
|
version: u32,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::DownloadJava { vendor, version }).await
|
|
}
|
|
|
|
pub async fn install_pack_to_existing_instance(
|
|
instance_id: String,
|
|
location: CreatePackLocation,
|
|
post_install_edit: Option<InstallPostInstallEdit>,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::InstallPackToExistingInstance {
|
|
instance_id,
|
|
location,
|
|
post_install_edit,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn update_managed_curseforge_modpack(
|
|
instance_id: String,
|
|
file_id: u32,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
start(InstallRequest::UpdateManagedCurseForgeModpack {
|
|
instance_id,
|
|
file_id,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn list_jobs(
|
|
include_finished: bool,
|
|
) -> crate::Result<Vec<InstallJobSnapshot>> {
|
|
let state = State::get().await?;
|
|
Ok(store::list(include_finished, &state)
|
|
.await?
|
|
.into_iter()
|
|
.map(|job| job.snapshot())
|
|
.collect())
|
|
}
|
|
|
|
pub async fn get_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
|
let state = State::get().await?;
|
|
Ok(store::get_required(job_id, &state).await?.snapshot())
|
|
}
|
|
|
|
pub async fn job_support_details(job_id: Uuid) -> crate::Result<String> {
|
|
let state = State::get().await?;
|
|
let job = store::get_required(job_id, &state).await?;
|
|
diagnostics::build_job_support_details(&job, &state).await
|
|
}
|
|
|
|
pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
|
let state = State::get().await?;
|
|
let mut job = store::get_required(job_id, &state).await?;
|
|
|
|
if !matches!(
|
|
job.status,
|
|
InstallJobStatus::Failed | InstallJobStatus::Interrupted
|
|
) {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Only failed or interrupted install jobs can be retried"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
job.state.target = job.state.request.target();
|
|
job.state.cleanup = job.state.request.cleanup();
|
|
job.state.rollback = None;
|
|
job.state.error = None;
|
|
job.state.rollback_error = None;
|
|
job.state.pause_reason = None;
|
|
job.state.continuation = None;
|
|
job.state.context = None;
|
|
job.state.progress.phase = initial_phase_for_request(&job.state.request);
|
|
job.state.progress.progress = None;
|
|
job.state.progress.details = InstallPhaseDetails::Empty;
|
|
job.state.progress.parallel = None;
|
|
prepare_initial_instance(&mut job.state, &state).await?;
|
|
job.state.record_event(InstallJobEventKind::JobQueued {
|
|
kind: job.state.request.kind(),
|
|
});
|
|
|
|
let record = store::update_status(
|
|
job_id,
|
|
InstallJobStatus::Queued,
|
|
&job.state,
|
|
&state,
|
|
)
|
|
.await?;
|
|
emit_install_job(&record.snapshot()).await?;
|
|
spawn_job(job_id);
|
|
|
|
// The spawned job may already have progressed (or finished) by the time
|
|
// the command returns; hand the caller the freshest stored state.
|
|
Ok(store::get_required(job_id, &state).await?.snapshot())
|
|
}
|
|
|
|
pub async fn repair_cache_and_retry_job(
|
|
job_id: Uuid,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
let state = State::get().await?;
|
|
let initial_job = store::get_required(job_id, &state).await?;
|
|
let _ = validated_cache_repair_types(&initial_job)?;
|
|
|
|
let operation_lock = state
|
|
.install_job_operation_locks
|
|
.entry(job_id)
|
|
.or_default()
|
|
.clone();
|
|
let mut operation = operation_lock.lock().await;
|
|
if operation.cache_repair_started {
|
|
return Ok(store::get_required(job_id, &state).await?.snapshot());
|
|
}
|
|
|
|
let job = store::get_required(job_id, &state).await?;
|
|
let cache_types = validated_cache_repair_types(&job)?;
|
|
operation.cache_repair_started = true;
|
|
|
|
if let Err(error) =
|
|
crate::state::CachedEntry::purge_cache_types(&cache_types, &state.pool)
|
|
.await
|
|
{
|
|
operation.cache_repair_started = false;
|
|
return Err(crate::ErrorKind::OtherError(format!(
|
|
"Project cache cleanup failed; retry was not started: {error}"
|
|
))
|
|
.into());
|
|
}
|
|
|
|
retry_job(job_id).await.map_err(|error| {
|
|
crate::ErrorKind::OtherError(format!(
|
|
"Project cache was cleared, but retry could not be started: {error}"
|
|
))
|
|
.into()
|
|
})
|
|
}
|
|
|
|
fn validated_cache_repair_types(
|
|
job: &store::InstallJobRecord,
|
|
) -> crate::Result<Vec<crate::state::CacheValueType>> {
|
|
validated_cache_repair_types_for(job.status, job.state.error.as_ref())
|
|
}
|
|
|
|
fn validated_cache_repair_types_for(
|
|
status: InstallJobStatus,
|
|
error: Option<&InstallErrorView>,
|
|
) -> crate::Result<Vec<crate::state::CacheValueType>> {
|
|
if !matches!(
|
|
status,
|
|
InstallJobStatus::Failed | InstallJobStatus::Interrupted
|
|
) {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Only failed or interrupted install jobs can repair cache"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let error = error.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Install job has no cache repair error".to_string(),
|
|
)
|
|
})?;
|
|
if error.code != "cache_repair_required" {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Install job does not require cache repair".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let cache_types = error
|
|
.context
|
|
.as_ref()
|
|
.map(|context| context.cache_types.as_slice())
|
|
.unwrap_or_default();
|
|
if cache_types.is_empty() {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Install job has no repairable cache types".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut validated = Vec::new();
|
|
for cache_type in cache_types {
|
|
let cache_type =
|
|
crate::state::CacheValueType::from_repairable_str(cache_type)
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Cache type is not repairable: {cache_type}"
|
|
))
|
|
})?;
|
|
if !validated.contains(&cache_type) {
|
|
validated.push(cache_type);
|
|
}
|
|
}
|
|
Ok(validated)
|
|
}
|
|
|
|
pub async fn resume_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
|
let state = State::get().await?;
|
|
let job = store::get_required(job_id, &state).await?;
|
|
if job.status != InstallJobStatus::WaitingForUser {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Only install jobs waiting for user action can be resumed"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
queue_waiting_job(job_id, job.state, &state).await
|
|
}
|
|
|
|
pub async fn skip_missing_content_and_resume_job(
|
|
job_id: Uuid,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
let state = State::get().await?;
|
|
let job = store::get_required(job_id, &state).await?;
|
|
if job.status != InstallJobStatus::WaitingForUser {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Only install jobs waiting for user action can skip missing content"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
if matches!(
|
|
job.state.request,
|
|
InstallRequest::UpdateManagedCurseForgeModpack { .. }
|
|
) {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"CurseForge modpack version updates cannot skip required manual downloads"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let mut current_missing_paths = job
|
|
.snapshot()
|
|
.items
|
|
.into_iter()
|
|
.filter(|item| {
|
|
item.status == super::model::DownloadItemStatus::Failed
|
|
|| (item.status == super::model::DownloadItemStatus::Skipped
|
|
&& item.manual_url.is_some())
|
|
})
|
|
.map(|item| item.id)
|
|
.collect::<Vec<_>>();
|
|
let mut job_state = job.state;
|
|
let InstallPauseReason::MissingRequiredContent { paths, .. } =
|
|
job_state.pause_reason.as_ref().ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Install job has no missing content to skip".to_string(),
|
|
)
|
|
})?;
|
|
if current_missing_paths.is_empty() {
|
|
current_missing_paths = paths.clone();
|
|
}
|
|
if current_missing_paths.is_empty() {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Install job has no missing content to skip".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
job_state
|
|
.skipped_missing_content_paths
|
|
.extend(current_missing_paths);
|
|
job_state.skipped_missing_content_paths.sort_unstable();
|
|
job_state.skipped_missing_content_paths.dedup();
|
|
|
|
queue_waiting_job(job_id, job_state, &state).await
|
|
}
|
|
|
|
async fn queue_waiting_job(
|
|
job_id: Uuid,
|
|
mut job_state: InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
prepare_resumed_job(&mut job_state);
|
|
let Some(record) = store::update_status_if(
|
|
job_id,
|
|
InstallJobStatus::WaitingForUser,
|
|
InstallJobStatus::Queued,
|
|
&job_state,
|
|
&state,
|
|
)
|
|
.await?
|
|
else {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Install job is no longer waiting for user action".to_string(),
|
|
)
|
|
.into());
|
|
};
|
|
InstallProgressReporter::reset_job(job_id);
|
|
emit_install_job(&record.snapshot()).await?;
|
|
spawn_job(job_id);
|
|
Ok(store::get_required(job_id, &state).await?.snapshot())
|
|
}
|
|
|
|
fn prepare_resumed_job(job_state: &mut InstallJobState) {
|
|
job_state.pause_reason = None;
|
|
job_state.error = None;
|
|
job_state.rollback_error = None;
|
|
job_state.context = None;
|
|
job_state.active_downloads.clear();
|
|
job_state.record_event(InstallJobEventKind::JobQueued {
|
|
kind: job_state.request.kind(),
|
|
});
|
|
}
|
|
|
|
pub async fn retry_job_as_new(
|
|
job_id: Uuid,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
let state = State::get().await?;
|
|
let job = store::get_required(job_id, &state).await?;
|
|
if !matches!(
|
|
job.status,
|
|
InstallJobStatus::Failed
|
|
| InstallJobStatus::Interrupted
|
|
| InstallJobStatus::Canceled
|
|
) {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Only failed, interrupted, or canceled downloads can be retried"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let new_job = start(job.state.request).await?;
|
|
// The spawned job may already have progressed (or finished) by the time
|
|
// the command returns; hand the caller the freshest stored state.
|
|
Ok(store::get_required(new_job.job_id, &state)
|
|
.await?
|
|
.snapshot())
|
|
}
|
|
|
|
pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
|
let state = State::get().await?;
|
|
let mut job = loop {
|
|
let mut job = store::get_required(job_id, &state).await?;
|
|
match job.status {
|
|
InstallJobStatus::Running => {
|
|
let Some(record) = store::update_status_if(
|
|
job_id,
|
|
InstallJobStatus::Running,
|
|
InstallJobStatus::Canceling,
|
|
&job.state,
|
|
&state,
|
|
)
|
|
.await?
|
|
else {
|
|
continue;
|
|
};
|
|
if let Some(token) =
|
|
state.install_job_cancellations.get(&job_id)
|
|
{
|
|
token.cancel();
|
|
}
|
|
emit_install_job(&record.snapshot()).await?;
|
|
return Ok(record.snapshot());
|
|
}
|
|
InstallJobStatus::Canceling => return Ok(job.snapshot()),
|
|
InstallJobStatus::Queued | InstallJobStatus::WaitingForUser => {
|
|
let expected = job.status;
|
|
begin_canceling_job(&mut job.state);
|
|
let Some(record) = store::update_status_if(
|
|
job_id,
|
|
expected,
|
|
InstallJobStatus::Canceling,
|
|
&job.state,
|
|
&state,
|
|
)
|
|
.await?
|
|
else {
|
|
continue;
|
|
};
|
|
emit_install_job(&record.snapshot()).await?;
|
|
break record;
|
|
}
|
|
_ => {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Only queued, running, or waiting install jobs can be canceled"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
};
|
|
|
|
let cleanup_succeeded =
|
|
match recovery::apply_cleanup(&mut job.state, &state).await {
|
|
Ok(()) => true,
|
|
Err(error) => {
|
|
job.state.rollback_error = Some(InstallErrorView::from_error(
|
|
"rollback_error",
|
|
InstallPhaseId::RollingBack,
|
|
&error,
|
|
None,
|
|
));
|
|
job.state.record_event(InstallJobEventKind::RollbackFailed {
|
|
message: error.to_string(),
|
|
});
|
|
false
|
|
}
|
|
};
|
|
recovery::finalize_rollback_state(&mut job.state, cleanup_succeeded);
|
|
if cleanup_succeeded {
|
|
clear_deleted_new_instance_id(&mut job.state);
|
|
}
|
|
let record = store::update_status(
|
|
job_id,
|
|
InstallJobStatus::Canceled,
|
|
&job.state,
|
|
&state,
|
|
)
|
|
.await?;
|
|
emit_install_job(&record.snapshot()).await?;
|
|
|
|
Ok(record.snapshot())
|
|
}
|
|
|
|
fn begin_canceling_job(job_state: &mut InstallJobState) {
|
|
let canceled_phase = job_state.progress.phase;
|
|
job_state.error = Some(InstallErrorView::from_message(
|
|
"canceled",
|
|
canceled_phase,
|
|
"Install was canceled",
|
|
));
|
|
job_state.pause_reason = None;
|
|
job_state.record_event(InstallJobEventKind::JobCanceled {
|
|
phase: canceled_phase,
|
|
});
|
|
job_state.progress.phase = InstallPhaseId::RollingBack;
|
|
job_state.progress.progress = None;
|
|
job_state.progress.details = InstallPhaseDetails::Empty;
|
|
job_state.progress.parallel = None;
|
|
job_state.record_event(InstallJobEventKind::RollbackStarted {
|
|
cleanup: job_state.cleanup.clone(),
|
|
});
|
|
}
|
|
|
|
pub async fn dismiss_job(job_id: Uuid) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
store::dismiss(job_id, &state).await
|
|
}
|
|
|
|
pub async fn clear_job_history() -> crate::Result<u64> {
|
|
let state = State::get().await?;
|
|
store::clear_finished(&state).await
|
|
}
|
|
|
|
async fn start(request: InstallRequest) -> crate::Result<InstallJobSnapshot> {
|
|
let state = State::get().await?;
|
|
let id = Uuid::new_v4();
|
|
let mut job_state = InstallJobState::new(request);
|
|
prepare_initial_instance(&mut job_state, &state).await?;
|
|
let record =
|
|
match store::insert(id, &job_state, InstallJobStatus::Queued, &state)
|
|
.await
|
|
{
|
|
Ok(record) => record,
|
|
Err(error) => {
|
|
return Err(cleanup_failed_initial_install(
|
|
&mut job_state,
|
|
&state,
|
|
error,
|
|
)
|
|
.await);
|
|
}
|
|
};
|
|
emit_install_job(&record.snapshot()).await?;
|
|
spawn_job(id);
|
|
Ok(record.snapshot())
|
|
}
|
|
|
|
async fn cleanup_failed_initial_install(
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
error: crate::Error,
|
|
) -> crate::Error {
|
|
match recovery::apply_cleanup(job_state, state).await {
|
|
Ok(()) => error,
|
|
Err(cleanup_error) => crate::ErrorKind::OtherError(format!(
|
|
"Install initialization failed: {error}; cleanup also failed: {cleanup_error}"
|
|
))
|
|
.into(),
|
|
}
|
|
}
|
|
|
|
async fn prepare_initial_instance(
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
match job_state.request.clone() {
|
|
InstallRequest::CreateInstance {
|
|
name,
|
|
mut game_version,
|
|
mut loader,
|
|
mut loader_version,
|
|
mut adjuncts,
|
|
icon_path,
|
|
link,
|
|
game_dir_override,
|
|
} => {
|
|
if let InstanceLink::CurseForgeModpack {
|
|
project_id,
|
|
version_id,
|
|
} = &link
|
|
{
|
|
let project_id = project_id.parse::<u32>().map_err(|_| {
|
|
ErrorKind::InputError(
|
|
"CurseForge project ID is invalid".to_string(),
|
|
)
|
|
})?;
|
|
let file_id = version_id.parse::<u32>().map_err(|_| {
|
|
ErrorKind::InputError(
|
|
"CurseForge file ID is invalid".to_string(),
|
|
)
|
|
})?;
|
|
let target = crate::api::curseforge::get_modpack_target(
|
|
project_id, file_id,
|
|
)
|
|
.await?;
|
|
game_version = target.game_version;
|
|
loader = target.loader;
|
|
loader_version = target.loader_version;
|
|
adjuncts.clear();
|
|
job_state.request = InstallRequest::CreateInstance {
|
|
name: name.clone(),
|
|
game_version: game_version.clone(),
|
|
loader,
|
|
loader_version: loader_version.clone(),
|
|
adjuncts: Vec::new(),
|
|
icon_path: icon_path.clone(),
|
|
link: link.clone(),
|
|
game_dir_override: game_dir_override.clone(),
|
|
};
|
|
}
|
|
resolve_required_adjuncts(
|
|
&game_version,
|
|
loader,
|
|
&mut adjuncts,
|
|
state,
|
|
)
|
|
.await?;
|
|
job_state.request = InstallRequest::CreateInstance {
|
|
name: name.clone(),
|
|
game_version: game_version.clone(),
|
|
loader,
|
|
loader_version: loader_version.clone(),
|
|
adjuncts: adjuncts.clone(),
|
|
icon_path: icon_path.clone(),
|
|
link: link.clone(),
|
|
game_dir_override: game_dir_override.clone(),
|
|
};
|
|
let metadata = crate::api::instance::create(
|
|
name,
|
|
game_version,
|
|
loader,
|
|
loader_version,
|
|
icon_path,
|
|
link,
|
|
None,
|
|
game_dir_override,
|
|
)
|
|
.await?;
|
|
if !adjuncts.is_empty() {
|
|
let mut components = metadata.loader_components.clone();
|
|
for adjunct in &mut adjuncts {
|
|
adjunct.instance_id = metadata.instance.id.clone();
|
|
adjunct.role = crate::state::LoaderComponentRole::Adjunct;
|
|
}
|
|
components.extend(adjuncts);
|
|
validate_loader_components(&components)?;
|
|
crate::state::instances::commands::replace_instance_loader_components(
|
|
&metadata.instance.id,
|
|
&components,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
}
|
|
set_display(
|
|
job_state,
|
|
metadata.instance.name,
|
|
metadata.instance.icon_path,
|
|
);
|
|
set_instance_id(job_state, metadata.instance.id);
|
|
}
|
|
InstallRequest::CreateModpackInstance {
|
|
location,
|
|
post_install_edit,
|
|
} => {
|
|
let preview = get_instance_from_pack(location).await?;
|
|
let name = post_install_edit
|
|
.as_ref()
|
|
.and_then(|edit| edit.name.clone())
|
|
.unwrap_or_else(|| preview.name.clone());
|
|
let icon_path = match post_install_edit
|
|
.as_ref()
|
|
.and_then(|edit| edit.icon_path.as_ref())
|
|
{
|
|
Some(icon_path) => icon_path.clone(),
|
|
None => preview
|
|
.icon
|
|
.as_ref()
|
|
.map(|path| path.to_string_lossy().to_string())
|
|
.or_else(|| preview.icon_url.clone()),
|
|
};
|
|
let link = post_install_edit
|
|
.as_ref()
|
|
.and_then(|edit| edit.link.clone())
|
|
.or_else(|| preview.link.clone())
|
|
.unwrap_or(InstanceLink::Unmanaged);
|
|
let metadata = crate::api::instance::create(
|
|
name,
|
|
preview.game_version,
|
|
preview.modloader,
|
|
preview.loader_version,
|
|
icon_path,
|
|
link,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
set_display(
|
|
job_state,
|
|
metadata.instance.name,
|
|
metadata.instance.icon_path,
|
|
);
|
|
set_instance_id(job_state, metadata.instance.id);
|
|
}
|
|
InstallRequest::ImportInstance {
|
|
instance_folder,
|
|
symlink: _,
|
|
base_path: _,
|
|
game_dir_override,
|
|
..
|
|
} => {
|
|
let metadata = crate::api::instance::create(
|
|
instance_folder,
|
|
"unknown".to_string(),
|
|
ModLoader::Vanilla,
|
|
None,
|
|
None,
|
|
InstanceLink::Unmanaged,
|
|
None,
|
|
game_dir_override,
|
|
)
|
|
.await?;
|
|
set_display(
|
|
job_state,
|
|
metadata.instance.name,
|
|
metadata.instance.icon_path,
|
|
);
|
|
set_instance_id(job_state, metadata.instance.id);
|
|
}
|
|
InstallRequest::DuplicateInstance { source_instance_id } => {
|
|
let metadata =
|
|
crate::state::get_instance(&source_instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Unknown instance".to_string(),
|
|
)
|
|
})?;
|
|
let created = crate::api::instance::create(
|
|
metadata.instance.name,
|
|
metadata.applied_content_set.game_version,
|
|
metadata.applied_content_set.loader,
|
|
metadata.applied_content_set.loader_version,
|
|
metadata.instance.icon_path,
|
|
metadata.link,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
set_display(
|
|
job_state,
|
|
created.instance.name,
|
|
created.instance.icon_path,
|
|
);
|
|
set_instance_id(job_state, created.instance.id);
|
|
}
|
|
InstallRequest::UpgradeUnmanagedInstance {
|
|
instance_id,
|
|
shared_upgrade_mode,
|
|
display_names,
|
|
..
|
|
} => {
|
|
let metadata =
|
|
crate::state::get_instance(&instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Unknown upgrade source instance".to_string(),
|
|
)
|
|
})?;
|
|
set_display(
|
|
job_state,
|
|
metadata.instance.name.clone(),
|
|
metadata.instance.icon_path.clone(),
|
|
);
|
|
match shared_upgrade_mode {
|
|
SharedUpgradeMode::Direct => {
|
|
prepare_existing_rollback(job_state, state, &instance_id)
|
|
.await?;
|
|
}
|
|
SharedUpgradeMode::CopyAndUpgrade => {
|
|
let created = crate::api::instance::create(
|
|
display_names.copy.unwrap_or_else(|| {
|
|
format!(
|
|
"{} (Upgraded Copy)",
|
|
metadata.instance.name
|
|
)
|
|
}),
|
|
metadata.applied_content_set.game_version.clone(),
|
|
metadata.applied_content_set.loader,
|
|
metadata.applied_content_set.loader_version.clone(),
|
|
metadata.instance.icon_path.clone(),
|
|
InstanceLink::Unmanaged,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
set_instance_id(job_state, created.instance.id.clone());
|
|
if let Err(error) = clone_instance_loader_components(
|
|
&metadata.loader_components,
|
|
&created.instance.id,
|
|
state,
|
|
)
|
|
.await
|
|
{
|
|
return Err(cleanup_failed_initial_install(
|
|
job_state, state, error,
|
|
)
|
|
.await);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
InstallRequest::InstallExistingInstance { instance_id, .. }
|
|
| InstallRequest::InstallPackToExistingInstance {
|
|
instance_id, ..
|
|
}
|
|
| InstallRequest::UpdateManagedCurseForgeModpack {
|
|
instance_id, ..
|
|
} => {
|
|
prepare_existing_rollback(job_state, state, &instance_id).await?;
|
|
}
|
|
InstallRequest::InstallContent {
|
|
instance_id,
|
|
display_title,
|
|
display_icon,
|
|
..
|
|
} => {
|
|
crate::state::get_instance(&instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Unknown instance {instance_id}"
|
|
))
|
|
})?;
|
|
set_display(job_state, display_title, display_icon);
|
|
}
|
|
InstallRequest::InstallCurseForgeContent {
|
|
request,
|
|
display_title,
|
|
display_icon,
|
|
} => {
|
|
crate::state::get_instance(&request.instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Unknown instance {}",
|
|
request.instance_id
|
|
))
|
|
})?;
|
|
set_display(job_state, display_title, display_icon);
|
|
}
|
|
InstallRequest::InstallCurseForgeWorld {
|
|
request,
|
|
display_title,
|
|
display_icon,
|
|
} => {
|
|
crate::state::get_instance(&request.instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Unknown instance {}",
|
|
request.instance_id
|
|
))
|
|
})?;
|
|
set_display(job_state, display_title, display_icon);
|
|
}
|
|
InstallRequest::DownloadJava { vendor, version } => {
|
|
set_display(job_state, format!("Java {version} ({vendor})"), None);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn spawn_job(job_id: Uuid) {
|
|
tokio::spawn(async move {
|
|
if let Err(error) = run_job(job_id).await {
|
|
tracing::error!("Install job {job_id} failed: {error}");
|
|
}
|
|
});
|
|
}
|
|
|
|
fn begin_failed_job_rollback(
|
|
job_state: &mut InstallJobState,
|
|
error: &crate::Error,
|
|
) {
|
|
let failed_phase = job_state.progress.phase;
|
|
let error_view =
|
|
install_error_view(failed_phase, error, job_state.context.clone());
|
|
job_state.record_event(InstallJobEventKind::Failed {
|
|
phase: failed_phase,
|
|
code: error_view.code.clone(),
|
|
message: error_view.message.clone(),
|
|
});
|
|
job_state.error = Some(error_view);
|
|
job_state.progress.phase = InstallPhaseId::RollingBack;
|
|
job_state.progress.progress = None;
|
|
job_state.progress.details = InstallPhaseDetails::Empty;
|
|
job_state.progress.parallel = None;
|
|
job_state.record_event(InstallJobEventKind::RollbackStarted {
|
|
cleanup: job_state.cleanup.clone(),
|
|
});
|
|
}
|
|
|
|
fn latest_failure_phase(
|
|
execution_state: &InstallJobState,
|
|
reporter_state: &InstallJobState,
|
|
) -> InstallPhaseId {
|
|
let latest_phase = |state: &InstallJobState| {
|
|
state
|
|
.events
|
|
.iter()
|
|
.rev()
|
|
.find_map(|event| match &event.kind {
|
|
InstallJobEventKind::PhaseStarted { phase, .. } => {
|
|
Some((event.at, *phase))
|
|
}
|
|
_ => None,
|
|
})
|
|
};
|
|
match (latest_phase(execution_state), latest_phase(reporter_state)) {
|
|
(Some(execution), Some(reporter)) if reporter.0 > execution.0 => {
|
|
reporter.1
|
|
}
|
|
(Some(execution), _) => execution.1,
|
|
(None, Some(reporter)) => reporter.1,
|
|
(None, None) => reporter_state.progress.phase,
|
|
}
|
|
}
|
|
|
|
fn begin_waiting_for_user(
|
|
job_state: &mut InstallJobState,
|
|
reason: InstallPauseReason,
|
|
) {
|
|
job_state.pause_reason = Some(reason.clone());
|
|
job_state.error = None;
|
|
job_state.rollback_error = None;
|
|
job_state.context = None;
|
|
job_state.progress.parallel = None;
|
|
job_state.record_event(InstallJobEventKind::WaitingForUser { reason });
|
|
}
|
|
|
|
async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let mut job = store::get_required(job_id, &state).await?;
|
|
|
|
if job.status != InstallJobStatus::Queued {
|
|
return Ok(());
|
|
}
|
|
|
|
let _install_permit = state.install_job_semaphore.acquire().await?;
|
|
job = store::get_required(job_id, &state).await?;
|
|
|
|
if job.status != InstallJobStatus::Queued {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut job_state = job.state.clone();
|
|
job_state.record_event(InstallJobEventKind::JobStarted);
|
|
let Some(record) = store::update_status_if(
|
|
job_id,
|
|
InstallJobStatus::Queued,
|
|
InstallJobStatus::Running,
|
|
&job_state,
|
|
&state,
|
|
)
|
|
.await?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let cancellation = tokio_util::sync::CancellationToken::new();
|
|
state
|
|
.install_job_cancellations
|
|
.insert(job_id, cancellation.clone());
|
|
emit_install_job(&record.snapshot()).await?;
|
|
if store::get_required(job_id, &state).await?.status
|
|
== InstallJobStatus::Canceling
|
|
{
|
|
cancellation.cancel();
|
|
}
|
|
let live_reporter = InstallProgressReporter::new(job_id, job_state.clone());
|
|
|
|
enum RunResult {
|
|
Completed(crate::Result<InstallExecutionOutcome<Option<String>>>),
|
|
Canceled,
|
|
}
|
|
|
|
let result = tokio::select! {
|
|
biased;
|
|
_ = cancellation.cancelled() => RunResult::Canceled,
|
|
result = run_request(job_id, &mut job_state, &state) => RunResult::Completed(result),
|
|
};
|
|
state.install_job_cancellations.remove(&job_id);
|
|
let execution_state = job_state;
|
|
let reporter_state = live_reporter.current_state().await?;
|
|
let failure_phase = latest_failure_phase(&execution_state, &reporter_state);
|
|
job_state = reporter_state;
|
|
|
|
match result {
|
|
RunResult::Completed(Ok(InstallExecutionOutcome::Completed(
|
|
instance_id,
|
|
))) => {
|
|
if let Some(instance_id) = instance_id.as_ref() {
|
|
set_instance_id(&mut job_state, instance_id.clone());
|
|
}
|
|
if cancellation.is_cancelled() {
|
|
finish_canceled_job(job_id, &mut job_state, &state).await?;
|
|
return Ok(());
|
|
}
|
|
job_state.record_event(InstallJobEventKind::JobSucceeded {
|
|
instance_id: current_instance_id(&job_state),
|
|
});
|
|
job_state.progress.phase = if matches!(
|
|
job_state.request,
|
|
InstallRequest::UpgradeUnmanagedInstance { .. }
|
|
) {
|
|
InstallPhaseId::Completed
|
|
} else {
|
|
InstallPhaseId::Finalizing
|
|
};
|
|
job_state.progress.progress = None;
|
|
job_state.progress.details = InstallPhaseDetails::Empty;
|
|
job_state.progress.parallel = None;
|
|
job_state.error = None;
|
|
job_state.rollback_error = None;
|
|
job_state.pause_reason = None;
|
|
job_state.continuation = None;
|
|
job_state.missing_content = None;
|
|
job_state.skipped_missing_content_paths.clear();
|
|
job_state.context = None;
|
|
let mut completed_state = job_state.clone();
|
|
completed_state.rollback = None;
|
|
let Some(record) =
|
|
store::complete_running_job(job_id, &completed_state, &state)
|
|
.await?
|
|
else {
|
|
if store::get_required(job_id, &state).await?.status
|
|
== InstallJobStatus::Canceling
|
|
{
|
|
finish_canceled_job(job_id, &mut job_state, &state).await?;
|
|
}
|
|
return Ok(());
|
|
};
|
|
if let Some(instance_id) = instance_id.as_ref()
|
|
&& let Err(error) =
|
|
emit_instance(instance_id, InstancePayloadType::Edited)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
job_id = %job_id,
|
|
instance_id,
|
|
error = %error,
|
|
"Install job succeeded, but its final instance event could not be emitted"
|
|
);
|
|
}
|
|
if let Err(error) =
|
|
recovery::discard_content_rollback(&mut job_state, &state).await
|
|
{
|
|
tracing::warn!(
|
|
job_id = %job_id,
|
|
error = %error,
|
|
"Install job succeeded, but rollback staging could not be discarded"
|
|
);
|
|
}
|
|
if let Err(error) = emit_install_job(&record.snapshot()).await {
|
|
tracing::warn!(
|
|
job_id = %job_id,
|
|
error = %error,
|
|
"Install job succeeded, but its final event could not be emitted"
|
|
);
|
|
}
|
|
}
|
|
RunResult::Completed(Ok(InstallExecutionOutcome::WaitingForUser(
|
|
reason,
|
|
))) => {
|
|
let mut waiting_state = job_state.clone();
|
|
begin_waiting_for_user(&mut waiting_state, reason);
|
|
let Some(record) = store::update_status_if(
|
|
job_id,
|
|
InstallJobStatus::Running,
|
|
InstallJobStatus::WaitingForUser,
|
|
&waiting_state,
|
|
&state,
|
|
)
|
|
.await?
|
|
else {
|
|
if store::get_required(job_id, &state).await?.status
|
|
== InstallJobStatus::Canceling
|
|
{
|
|
finish_canceled_job(job_id, &mut job_state, &state).await?;
|
|
}
|
|
return Ok(());
|
|
};
|
|
emit_install_job(&record.snapshot()).await?;
|
|
}
|
|
RunResult::Canceled => {
|
|
finish_canceled_job(job_id, &mut job_state, &state).await?;
|
|
}
|
|
RunResult::Completed(Err(error)) => {
|
|
job_state.progress.phase = failure_phase;
|
|
begin_failed_job_rollback(&mut job_state, &error);
|
|
let cleanup_succeeded = match recovery::apply_cleanup(
|
|
&mut job_state,
|
|
&state,
|
|
)
|
|
.await
|
|
{
|
|
Err(rollback_error) => {
|
|
tracing::error!(
|
|
"Error rolling back failed install job {job_id}: {rollback_error}"
|
|
);
|
|
job_state.rollback_error = Some(install_error_view(
|
|
InstallPhaseId::RollingBack,
|
|
&rollback_error,
|
|
None,
|
|
));
|
|
job_state.record_event(
|
|
InstallJobEventKind::RollbackFailed {
|
|
message: rollback_error.to_string(),
|
|
},
|
|
);
|
|
false
|
|
}
|
|
Ok(()) => true,
|
|
};
|
|
recovery::finalize_rollback_state(
|
|
&mut job_state,
|
|
cleanup_succeeded,
|
|
);
|
|
if cleanup_succeeded {
|
|
clear_deleted_new_instance_id(&mut job_state);
|
|
}
|
|
let record = store::update_status(
|
|
job_id,
|
|
InstallJobStatus::Failed,
|
|
&job_state,
|
|
&state,
|
|
)
|
|
.await?;
|
|
emit_install_job(&record.snapshot()).await?;
|
|
return Err(error);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn finish_canceled_job(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let canceled_phase = job_state.progress.phase;
|
|
job_state.error = Some(InstallErrorView::from_message(
|
|
"canceled",
|
|
canceled_phase,
|
|
"Install was canceled",
|
|
));
|
|
job_state.pause_reason = None;
|
|
job_state.record_event(InstallJobEventKind::JobCanceled {
|
|
phase: canceled_phase,
|
|
});
|
|
job_state.progress.phase = InstallPhaseId::RollingBack;
|
|
job_state.progress.progress = None;
|
|
job_state.progress.details = InstallPhaseDetails::Empty;
|
|
job_state.record_event(InstallJobEventKind::RollbackStarted {
|
|
cleanup: job_state.cleanup.clone(),
|
|
});
|
|
let cleanup_succeeded =
|
|
match recovery::apply_cleanup(job_state, state).await {
|
|
Err(rollback_error) => {
|
|
job_state.rollback_error = Some(install_error_view(
|
|
InstallPhaseId::RollingBack,
|
|
&rollback_error,
|
|
None,
|
|
));
|
|
job_state.record_event(InstallJobEventKind::RollbackFailed {
|
|
message: rollback_error.to_string(),
|
|
});
|
|
false
|
|
}
|
|
Ok(()) => true,
|
|
};
|
|
recovery::finalize_rollback_state(job_state, cleanup_succeeded);
|
|
if cleanup_succeeded {
|
|
clear_deleted_new_instance_id(job_state);
|
|
}
|
|
let record = store::update_status(
|
|
job_id,
|
|
InstallJobStatus::Canceled,
|
|
job_state,
|
|
state,
|
|
)
|
|
.await?;
|
|
emit_install_job(&record.snapshot()).await
|
|
}
|
|
|
|
async fn run_request(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
) -> crate::Result<InstallExecutionOutcome<Option<String>>> {
|
|
match job_state.request.clone() {
|
|
InstallRequest::CreateInstance {
|
|
name,
|
|
game_version,
|
|
loader,
|
|
loader_version: _,
|
|
adjuncts,
|
|
icon_path: _,
|
|
link,
|
|
game_dir_override: _,
|
|
} => {
|
|
let Some(instance_id) = current_instance_id(job_state) else {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Install job is missing its instance id".to_string(),
|
|
)
|
|
.into());
|
|
};
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::PreparingInstance,
|
|
InstallPhaseDetails::Instance { name: name.clone() },
|
|
)
|
|
.await?;
|
|
let reporter =
|
|
InstallProgressReporter::new(job_id, job_state.clone());
|
|
if let InstanceLink::CurseForgeModpack {
|
|
project_id,
|
|
version_id,
|
|
} = link
|
|
{
|
|
let project_id = project_id.parse::<u32>().map_err(|_| {
|
|
ErrorKind::InputError(
|
|
"CurseForge project ID is invalid".to_string(),
|
|
)
|
|
})?;
|
|
let file_id = version_id.parse::<u32>().map_err(|_| {
|
|
ErrorKind::InputError(
|
|
"CurseForge file ID is invalid".to_string(),
|
|
)
|
|
})?;
|
|
crate::state::instances::commands::set_instance_install_stage(
|
|
&instance_id,
|
|
InstanceInstallStage::PackInstalling,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
emit_instance(&instance_id, InstancePayloadType::Edited)
|
|
.await?;
|
|
let result = crate::api::curseforge::install_modpack_with_reporter(
|
|
crate::api::curseforge::CurseForgeModpackInstallRequest {
|
|
instance_id: instance_id.clone(),
|
|
project_id,
|
|
file_id,
|
|
install_optional: false,
|
|
allow_target_change: false,
|
|
},
|
|
Some(reporter.clone()),
|
|
)
|
|
.await?;
|
|
if let Some(reason) = curseforge_manual_download_pause(
|
|
&result,
|
|
&job_state.skipped_missing_content_paths,
|
|
) {
|
|
return Ok(InstallExecutionOutcome::WaitingForUser(reason));
|
|
}
|
|
}
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
None,
|
|
InstallPhaseDetails::Minecraft {
|
|
game_version: game_version.clone(),
|
|
loader,
|
|
},
|
|
)
|
|
.await?;
|
|
let context =
|
|
crate::state::instances::commands::get_instance_launch_context(
|
|
&instance_id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
crate::launcher::install_minecraft_with_reporter(
|
|
&context,
|
|
false,
|
|
Some(reporter.clone()),
|
|
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
|
|
)
|
|
.await?;
|
|
install_adjunct_components(
|
|
state,
|
|
&instance_id,
|
|
&adjuncts,
|
|
&game_version,
|
|
loader,
|
|
reporter.cancellation_token(),
|
|
)
|
|
.await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::CreateModpackInstance {
|
|
location,
|
|
post_install_edit,
|
|
} => {
|
|
let Some(instance_id) = current_instance_id(job_state) else {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Install job is missing its instance id".to_string(),
|
|
)
|
|
.into());
|
|
};
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::ResolvingPack,
|
|
modpack_details(&location),
|
|
)
|
|
.await?;
|
|
if let InstallExecutionOutcome::WaitingForUser(reason) =
|
|
install_pack(
|
|
job_id,
|
|
job_state,
|
|
location,
|
|
instance_id.clone(),
|
|
DownloadReason::Modpack,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(InstallExecutionOutcome::WaitingForUser(reason));
|
|
}
|
|
apply_post_install_edit(&instance_id, post_install_edit).await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::ImportInstance {
|
|
launcher_type,
|
|
base_path,
|
|
instance_folder,
|
|
instance_path,
|
|
symlink,
|
|
game_version,
|
|
loader,
|
|
loader_version,
|
|
game_dir_override: _,
|
|
} => {
|
|
tracing::debug!(
|
|
"InstallRequest::ImportInstance: launcher_type={launcher_type} base_path={} instance_folder={instance_folder} symlink={symlink}",
|
|
base_path.display()
|
|
);
|
|
let Some(instance_id) = current_instance_id(job_state) else {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Install job is missing its instance id".to_string(),
|
|
)
|
|
.into());
|
|
};
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::PreparingInstance,
|
|
InstallPhaseDetails::Import {
|
|
launcher_type,
|
|
instance_folder: instance_folder.clone(),
|
|
},
|
|
)
|
|
.await?;
|
|
crate::api::pack::import::import_instance_with_reporter(
|
|
&instance_id,
|
|
launcher_type,
|
|
base_path,
|
|
instance_folder,
|
|
instance_path,
|
|
crate::api::pack::import::ImportOverrides {
|
|
game_version,
|
|
loader,
|
|
loader_version,
|
|
},
|
|
// TODO(B2): apply overrides to launcher-specific importers
|
|
// (MultiMC/Prism/ATLauncher/GDLauncher/Curseforge/ModrinthApp);
|
|
// generic/PCL/HMCL/Axolotl paths already consume them.
|
|
InstallProgressReporter::new(job_id, job_state.clone()),
|
|
symlink,
|
|
)
|
|
.await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::DuplicateInstance { source_instance_id } => {
|
|
let Some(instance_id) = current_instance_id(job_state) else {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Install job is missing its instance id".to_string(),
|
|
)
|
|
.into());
|
|
};
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::PreparingInstance,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let state = State::get().await?;
|
|
crate::api::pack::import::copy_dotminecraft_with_reporter(
|
|
&instance_id,
|
|
crate::api::instance::get_full_path(&source_instance_id)
|
|
.await?,
|
|
&state.io_semaphore,
|
|
InstallProgressReporter::new(job_id, job_state.clone()),
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let context =
|
|
crate::state::instances::commands::get_instance_launch_context(
|
|
&instance_id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
crate::launcher::install_minecraft_with_reporter(
|
|
&context,
|
|
false,
|
|
Some(InstallProgressReporter::new(job_id, job_state.clone())),
|
|
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
|
|
)
|
|
.await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::UpgradeUnmanagedInstance {
|
|
instance_id: source_instance_id,
|
|
plan_id,
|
|
execution,
|
|
create_full_backup,
|
|
shared_upgrade_mode,
|
|
display_names,
|
|
} => {
|
|
let target_instance_id = current_instance_id(job_state)
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Upgrade job is missing its target instance id"
|
|
.to_string(),
|
|
)
|
|
})?;
|
|
run_instance_upgrade(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
&source_instance_id,
|
|
&target_instance_id,
|
|
&plan_id,
|
|
execution,
|
|
create_full_backup,
|
|
shared_upgrade_mode,
|
|
display_names,
|
|
)
|
|
.await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(target_instance_id)))
|
|
}
|
|
InstallRequest::InstallExistingInstance { instance_id, force } => {
|
|
prepare_existing_rollback(job_state, state, &instance_id).await?;
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let context =
|
|
crate::state::instances::commands::get_instance_launch_context(
|
|
&instance_id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
crate::launcher::install_minecraft_with_reporter(
|
|
&context,
|
|
force,
|
|
Some(InstallProgressReporter::new(job_id, job_state.clone())),
|
|
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
|
|
)
|
|
.await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::InstallPackToExistingInstance {
|
|
instance_id,
|
|
location,
|
|
post_install_edit,
|
|
} => {
|
|
prepare_existing_rollback(job_state, state, &instance_id).await?;
|
|
let disabled_project_ids = match job_state.continuation.clone() {
|
|
Some(InstallContinuationState::InstallingPackToExistingInstance {
|
|
disabled_project_ids,
|
|
}) => disabled_project_ids.into_iter().collect(),
|
|
None => {
|
|
let disabled_project_ids = remove_existing_pack_content(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
&instance_id,
|
|
)
|
|
.await?;
|
|
let mut persisted_ids = disabled_project_ids
|
|
.iter()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
persisted_ids.sort_unstable();
|
|
let continuation = InstallContinuationState::InstallingPackToExistingInstance {
|
|
disabled_project_ids: persisted_ids,
|
|
};
|
|
job_state.continuation = Some(continuation.clone());
|
|
InstallProgressReporter::new(job_id, job_state.clone())
|
|
.set_continuation(Some(continuation))
|
|
.await?;
|
|
disabled_project_ids
|
|
}
|
|
};
|
|
if let InstallExecutionOutcome::WaitingForUser(reason) =
|
|
install_pack(
|
|
job_id,
|
|
job_state,
|
|
location,
|
|
instance_id.clone(),
|
|
DownloadReason::Modpack,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(InstallExecutionOutcome::WaitingForUser(reason));
|
|
}
|
|
restore_disabled_projects(
|
|
&instance_id,
|
|
disabled_project_ids,
|
|
state,
|
|
)
|
|
.await?;
|
|
job_state.continuation = None;
|
|
InstallProgressReporter::new(job_id, job_state.clone())
|
|
.set_continuation(None)
|
|
.await?;
|
|
apply_post_install_edit(&instance_id, post_install_edit).await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::InstallContent {
|
|
instance_id,
|
|
project_id,
|
|
version_id,
|
|
content_type,
|
|
selected,
|
|
excluded_project_ids,
|
|
display_title: _,
|
|
display_icon: _,
|
|
} => {
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::DownloadingContent,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let plan = crate::state::instances::commands::resolve_install_plan(
|
|
&instance_id,
|
|
crate::state::instances::commands::InstanceInstallProjectRequest {
|
|
project_id: project_id.clone(),
|
|
version_id,
|
|
content_type,
|
|
selected,
|
|
excluded_project_ids,
|
|
force_project_ids: Vec::new(),
|
|
},
|
|
state,
|
|
)
|
|
.await?;
|
|
let total = (plan.dependencies.len() + 1) as u64;
|
|
let reporter =
|
|
InstallProgressReporter::new(job_id, job_state.clone());
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingContent,
|
|
Some(InstallProgress {
|
|
current: 0,
|
|
total,
|
|
secondary: None,
|
|
}),
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
crate::state::instances::commands::install_resolved_content_plan_with_reporter(
|
|
&instance_id,
|
|
&plan,
|
|
Some(reporter.clone()),
|
|
state,
|
|
)
|
|
.await?;
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingContent,
|
|
Some(InstallProgress {
|
|
current: total,
|
|
total,
|
|
secondary: None,
|
|
}),
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
crate::api::instance::emit_content_changed(&instance_id).await?;
|
|
let dependency_project_ids = plan
|
|
.dependencies
|
|
.iter()
|
|
.map(|dependency| dependency.project_id.clone())
|
|
.collect::<Vec<_>>();
|
|
emit_instance(
|
|
&instance_id,
|
|
InstancePayloadType::ContentInstallFinished {
|
|
project_ids: std::iter::once(project_id.clone())
|
|
.chain(dependency_project_ids.iter().cloned())
|
|
.collect(),
|
|
dependency_project_ids,
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::InstallCurseForgeContent {
|
|
request,
|
|
display_title: _,
|
|
display_icon: _,
|
|
} => {
|
|
let instance_id = request.instance_id.clone();
|
|
let primary_project_id = request.project_id;
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::DownloadingContent,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let reporter =
|
|
InstallProgressReporter::new(job_id, job_state.clone());
|
|
let result = crate::api::curseforge::install_file_with_reporter(
|
|
request, reporter,
|
|
)
|
|
.await?;
|
|
crate::api::instance::emit_content_changed(&instance_id).await?;
|
|
let dependency_project_ids = result
|
|
.installed
|
|
.iter()
|
|
.filter(|installed| installed.dependency)
|
|
.map(|installed| format!("curseforge:{}", installed.project_id))
|
|
.collect::<Vec<_>>();
|
|
emit_instance(
|
|
&instance_id,
|
|
InstancePayloadType::ContentInstallFinished {
|
|
project_ids: std::iter::once(format!(
|
|
"curseforge:{primary_project_id}"
|
|
))
|
|
.chain(dependency_project_ids.iter().cloned())
|
|
.collect(),
|
|
dependency_project_ids,
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::InstallCurseForgeWorld {
|
|
request,
|
|
display_title: _,
|
|
display_icon: _,
|
|
} => {
|
|
let instance_id = request.instance_id.clone();
|
|
if curseforge_world_was_imported_manually(job_state, &request) {
|
|
return Ok(InstallExecutionOutcome::Completed(Some(
|
|
instance_id,
|
|
)));
|
|
}
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::DownloadingContent,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let reporter =
|
|
InstallProgressReporter::new(job_id, job_state.clone());
|
|
let result = crate::api::curseforge::install_world_with_reporter(
|
|
request.clone(),
|
|
reporter.clone(),
|
|
)
|
|
.await?;
|
|
if let Some(manual_download) = result.manual_download {
|
|
let path = format!("saves/{}", manual_download.file_name);
|
|
let manual_url = manual_download.website_url.clone().or_else(|| {
|
|
Some(format!(
|
|
"https://www.curseforge.com/minecraft/worlds/{}/download/{}",
|
|
manual_download.project_slug, manual_download.file_id
|
|
))
|
|
});
|
|
reporter
|
|
.record_events(vec![
|
|
InstallJobEventKind::ContentFileSkipped {
|
|
path: path.clone(),
|
|
reason: "CurseForge requires a manual download"
|
|
.to_string(),
|
|
project_id: Some(
|
|
manual_download.project_id.to_string(),
|
|
),
|
|
version_id: Some(
|
|
manual_download.file_id.to_string(),
|
|
),
|
|
manual_url,
|
|
},
|
|
])
|
|
.await?;
|
|
return Ok(InstallExecutionOutcome::WaitingForUser(
|
|
InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec![path],
|
|
},
|
|
));
|
|
}
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::UpdateManagedCurseForgeModpack {
|
|
instance_id,
|
|
file_id,
|
|
} => {
|
|
prepare_existing_rollback(job_state, state, &instance_id).await?;
|
|
crate::state::instances::commands::set_instance_install_stage(
|
|
&instance_id,
|
|
InstanceInstallStage::PackInstalling,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::DownloadingContent,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let reporter =
|
|
InstallProgressReporter::new(job_id, job_state.clone());
|
|
let result =
|
|
crate::api::curseforge::update_managed_modpack_with_reporter(
|
|
&instance_id,
|
|
file_id,
|
|
Some(reporter.clone()),
|
|
)
|
|
.await?;
|
|
if !result.content.failed_downloads.is_empty() {
|
|
return Err(ErrorKind::NetworkError(format!(
|
|
"{} CurseForge files could not be downloaded automatically",
|
|
result.content.failed_downloads.len()
|
|
))
|
|
.into());
|
|
}
|
|
if let Some(reason) = curseforge_manual_download_pause(
|
|
&result,
|
|
&job_state.skipped_missing_content_paths,
|
|
) {
|
|
return Ok(InstallExecutionOutcome::WaitingForUser(reason));
|
|
}
|
|
Ok(InstallExecutionOutcome::Completed(Some(instance_id)))
|
|
}
|
|
InstallRequest::DownloadJava { vendor, version } => {
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::PreparingJava,
|
|
InstallPhaseDetails::Java {
|
|
major_version: version,
|
|
step: InstallJavaStep::FetchingMetadata,
|
|
},
|
|
)
|
|
.await?;
|
|
let reporter =
|
|
InstallProgressReporter::new(job_id, job_state.clone());
|
|
let path = crate::api::jre::download_java_from_feed_with_reporter(
|
|
&vendor, version, reporter,
|
|
)
|
|
.await?;
|
|
let _ = path;
|
|
Ok(InstallExecutionOutcome::Completed(None))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn apply_post_install_edit(
|
|
instance_id: &str,
|
|
edit: Option<InstallPostInstallEdit>,
|
|
) -> crate::Result<()> {
|
|
let Some(edit) = edit else {
|
|
return Ok(());
|
|
};
|
|
|
|
if edit.name.is_none() && edit.icon_path.is_none() && edit.link.is_none() {
|
|
return Ok(());
|
|
}
|
|
|
|
crate::api::instance::edit(
|
|
instance_id,
|
|
crate::state::instances::commands::EditInstance {
|
|
name: edit.name,
|
|
icon_path: edit.icon_path,
|
|
link: edit.link,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
enum StagedUpgradeDownload {
|
|
Modrinth(crate::state::instances::commands::DownloadedProjectVersion),
|
|
CurseForge(crate::api::curseforge::StagedCurseForgeUpgrade),
|
|
}
|
|
|
|
struct StagedUpgradeMutation {
|
|
existing_path: Option<String>,
|
|
target_path: String,
|
|
ownership: crate::state::instances::ContentOwnershipKind,
|
|
auto_dependency: bool,
|
|
enabled: bool,
|
|
download: StagedUpgradeDownload,
|
|
}
|
|
|
|
struct UpgradeStagingRequest {
|
|
index: usize,
|
|
provider: ContentProvider,
|
|
project_id: String,
|
|
release_id: String,
|
|
auto_dependency: bool,
|
|
enabled: bool,
|
|
existing_path: Option<String>,
|
|
ownership: crate::state::instances::ContentOwnershipKind,
|
|
project_type: Option<crate::state::ProjectType>,
|
|
}
|
|
|
|
struct AppliedUpgradeContent {
|
|
skipped: Vec<String>,
|
|
launcher_expected_files:
|
|
HashMap<String, Option<crate::state::InstanceUpgradeSourceFile>>,
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn run_instance_upgrade(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
source_instance_id: &str,
|
|
target_instance_id: &str,
|
|
plan_id: &str,
|
|
execution: InstanceUpgradeExecution,
|
|
create_full_backup: bool,
|
|
shared_upgrade_mode: SharedUpgradeMode,
|
|
display_names: InstanceUpgradeDisplayNames,
|
|
) -> crate::Result<()> {
|
|
let compatibility_warning_details =
|
|
upgrade_compatibility_warning_details(&execution);
|
|
let (upgrade_source_files, upgrade_watch) = if shared_upgrade_mode
|
|
== SharedUpgradeMode::CopyAndUpgrade
|
|
{
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::CreatingBackup,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
copy_physical_instance_contents(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
source_instance_id,
|
|
target_instance_id,
|
|
)
|
|
.await?;
|
|
let files = crate::state::instances::commands::scan_instance_upgrade_source_files(
|
|
target_instance_id,
|
|
state,
|
|
)
|
|
.await?;
|
|
let watch = state
|
|
.file_watcher
|
|
.track_upgrade_source(
|
|
target_instance_id,
|
|
files.iter().map(|file| file.relative_path.clone()),
|
|
)
|
|
.await
|
|
.map(|snapshot| InstanceUpgradeWatchBaseline {
|
|
epoch: snapshot.epoch,
|
|
generation: snapshot.generation,
|
|
dirty_paths: snapshot.dirty_paths.into_iter().collect(),
|
|
});
|
|
(files, watch)
|
|
} else {
|
|
(
|
|
execution.source_files.clone(),
|
|
execution.source_watch.clone(),
|
|
)
|
|
};
|
|
|
|
let backup_instance_id = if should_create_upgrade_backup(
|
|
create_full_backup,
|
|
shared_upgrade_mode,
|
|
) {
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::CreatingBackup,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
Some(
|
|
create_upgrade_backup(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
source_instance_id,
|
|
display_names.backup.as_deref(),
|
|
)
|
|
.await?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
InstallProgressReporter::new(job_id, job_state.clone())
|
|
.set_upgrade_result(InstanceUpgradeResult {
|
|
plan_id: plan_id.to_string(),
|
|
source_instance_id: source_instance_id.to_string(),
|
|
target_instance_id: target_instance_id.to_string(),
|
|
backup_instance_id: backup_instance_id.clone(),
|
|
source_environment: Some(execution.source_environment.clone()),
|
|
target_environment: Some(execution.target_environment.clone()),
|
|
solution: execution.solution.clone(),
|
|
compatibility_warnings: execution.warnings.clone(),
|
|
compatibility_warning_details: compatibility_warning_details
|
|
.clone(),
|
|
external_changes: Vec::new(),
|
|
skipped_due_to_external_conflict: Vec::new(),
|
|
})
|
|
.await?;
|
|
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::StagingContent,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::DownloadingContent,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let staged = stage_upgrade_content(
|
|
target_instance_id,
|
|
&execution,
|
|
Some(InstallProgressReporter::new(job_id, job_state.clone())),
|
|
state,
|
|
)
|
|
.await?;
|
|
|
|
let mut external_changes = collect_upgrade_external_changes(
|
|
target_instance_id,
|
|
&upgrade_source_files,
|
|
upgrade_watch.as_ref(),
|
|
state,
|
|
)
|
|
.await?;
|
|
for relative_path in
|
|
collect_unsafe_upgrade_paths(target_instance_id).await?
|
|
{
|
|
if !external_changes
|
|
.iter()
|
|
.any(|change| change.relative_path == relative_path)
|
|
{
|
|
external_changes.push(InstanceUpgradeExternalChange {
|
|
relative_path,
|
|
kind: InstanceUpgradeExternalChangeKind::Modified,
|
|
});
|
|
}
|
|
}
|
|
let mut external_paths = external_changes
|
|
.iter()
|
|
.map(|change| change.relative_path.clone())
|
|
.collect::<HashSet<_>>();
|
|
for mutation in &staged {
|
|
if let Some(path) = mutation.existing_path.as_deref()
|
|
&& source_file_changed(
|
|
path,
|
|
&upgrade_source_files,
|
|
target_instance_id,
|
|
)
|
|
.await?
|
|
&& external_paths.insert(path.to_string())
|
|
{
|
|
external_changes.push(InstanceUpgradeExternalChange {
|
|
relative_path: path.to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Modified,
|
|
});
|
|
}
|
|
}
|
|
if shared_upgrade_mode == SharedUpgradeMode::Direct {
|
|
let replacement_paths = staged
|
|
.iter()
|
|
.map(|mutation| mutation.target_path.clone())
|
|
.collect::<Vec<_>>();
|
|
recovery::prepare_existing_upgrade_content_rollback(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
replacement_paths,
|
|
)
|
|
.await?;
|
|
recovery::restore_upgrade_db_baseline(job_state, state).await?;
|
|
}
|
|
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::ApplyingContent,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let applied = apply_upgrade_content(
|
|
target_instance_id,
|
|
staged,
|
|
&execution,
|
|
&external_paths,
|
|
&upgrade_source_files,
|
|
state,
|
|
)
|
|
.await?;
|
|
if !applied.skipped.is_empty() {
|
|
InstallProgressReporter::new(job_id, job_state.clone())
|
|
.record_events(
|
|
applied
|
|
.skipped
|
|
.iter()
|
|
.map(|relative_path| {
|
|
InstallJobEventKind::UpgradeItemSkipped {
|
|
relative_path: relative_path.clone(),
|
|
reason: "external_conflict".to_string(),
|
|
}
|
|
})
|
|
.collect(),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::UpdatingLoader,
|
|
InstallPhaseDetails::Minecraft {
|
|
game_version: execution.target_environment.game_version.clone(),
|
|
loader: execution.target_environment.mod_loader,
|
|
},
|
|
)
|
|
.await?;
|
|
let upgraded_target_name = display_names
|
|
.should_auto_rename
|
|
.then(|| default_upgrade_instance_name(&execution.target_environment))
|
|
.or(display_names.upgraded_target.clone());
|
|
crate::state::edit_instance(
|
|
target_instance_id,
|
|
crate::state::EditInstance {
|
|
name: upgraded_target_name,
|
|
content_set_patch: Some(crate::state::AppliedContentSetPatch {
|
|
source_kind: Some(
|
|
crate::state::instances::ContentSourceKind::Local,
|
|
),
|
|
game_version: Some(
|
|
execution.target_environment.game_version.clone(),
|
|
),
|
|
loader: Some(execution.target_environment.mod_loader),
|
|
loader_version: Some(
|
|
execution.target_environment.mod_loader_version.clone(),
|
|
),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
let context =
|
|
crate::state::instances::commands::get_instance_launch_context(
|
|
target_instance_id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown upgrade target".to_string())
|
|
})?;
|
|
crate::launcher::install_minecraft_with_reporter(
|
|
&context,
|
|
false,
|
|
Some(InstallProgressReporter::new(job_id, job_state.clone())),
|
|
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
|
|
)
|
|
.await?;
|
|
|
|
update_progress(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
InstallPhaseId::Verifying,
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
crate::state::instances::commands::get_content_snapshot(
|
|
target_instance_id,
|
|
true,
|
|
state,
|
|
)
|
|
.await?;
|
|
let final_files =
|
|
crate::state::instances::commands::scan_instance_upgrade_source_files(
|
|
target_instance_id,
|
|
state,
|
|
)
|
|
.await?;
|
|
merge_upgrade_external_changes(
|
|
&mut external_changes,
|
|
final_upgrade_external_changes(
|
|
&upgrade_source_files,
|
|
&final_files,
|
|
&applied.launcher_expected_files,
|
|
),
|
|
);
|
|
if !external_changes.is_empty() {
|
|
InstallProgressReporter::new(job_id, job_state.clone())
|
|
.record_events(
|
|
external_changes
|
|
.iter()
|
|
.map(|change| InstallJobEventKind::UpgradeExternalChange {
|
|
relative_path: change.relative_path.clone(),
|
|
kind: change.kind,
|
|
})
|
|
.collect(),
|
|
)
|
|
.await?;
|
|
}
|
|
InstallProgressReporter::new(job_id, job_state.clone())
|
|
.set_upgrade_result(InstanceUpgradeResult {
|
|
plan_id: plan_id.to_string(),
|
|
source_instance_id: source_instance_id.to_string(),
|
|
target_instance_id: target_instance_id.to_string(),
|
|
backup_instance_id,
|
|
source_environment: Some(execution.source_environment),
|
|
target_environment: Some(execution.target_environment),
|
|
solution: execution.solution,
|
|
compatibility_warnings: execution.warnings,
|
|
compatibility_warning_details: compatibility_warning_details
|
|
.clone(),
|
|
external_changes,
|
|
skipped_due_to_external_conflict: applied.skipped,
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn default_upgrade_instance_name(
|
|
environment: &crate::state::InstanceUpgradeEnvironment,
|
|
) -> String {
|
|
let loader = match environment.mod_loader {
|
|
ModLoader::Vanilla => "Vanilla",
|
|
ModLoader::Forge => "Forge",
|
|
ModLoader::Fabric => "Fabric",
|
|
ModLoader::Quilt => "Quilt",
|
|
ModLoader::NeoForge => "NeoForge",
|
|
ModLoader::OptiFine => "OptiFine",
|
|
ModLoader::Cleanroom => "Cleanroom",
|
|
ModLoader::LiteLoader => "LiteLoader",
|
|
ModLoader::LegacyFabric => "Legacy Fabric",
|
|
ModLoader::Babric => "Babric",
|
|
};
|
|
let loader_version = environment
|
|
.mod_loader_version
|
|
.as_deref()
|
|
.filter(|version| !matches!(*version, "latest" | "stable"))
|
|
.map(|version| format!(" {version}"))
|
|
.unwrap_or_default();
|
|
format!("{}-{loader}{loader_version}", environment.game_version)
|
|
}
|
|
|
|
fn upgrade_compatibility_warning_details(
|
|
execution: &InstanceUpgradeExecution,
|
|
) -> Vec<InstanceUpgradeCompatibilityWarning> {
|
|
let physical_details = execution
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| {
|
|
let code = physical_upgrade_warning_code(execution, item)?;
|
|
execution
|
|
.warnings
|
|
.iter()
|
|
.any(|warning| warning.code == code)
|
|
.then(|| InstanceUpgradeCompatibilityWarning {
|
|
code,
|
|
relative_path: Some(item.relative_path.clone()),
|
|
content_id: Some(item.content_id.clone()),
|
|
provider: item.provider,
|
|
project_id: item.project_id.clone(),
|
|
conflicting_project_id: None,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let mut details = execution
|
|
.warnings
|
|
.iter()
|
|
.filter_map(|warning| {
|
|
let item = warning
|
|
.content_id
|
|
.as_ref()
|
|
.and_then(|content_id| {
|
|
execution
|
|
.items
|
|
.iter()
|
|
.find(|item| item.content_id == *content_id)
|
|
})
|
|
.or_else(|| {
|
|
let mut matches = execution.items.iter().filter(|item| {
|
|
item.provider == warning.provider
|
|
&& item.project_id == warning.project_id
|
|
});
|
|
let item = matches.next()?;
|
|
matches.next().is_none().then_some(item)
|
|
});
|
|
if item.is_none()
|
|
&& physical_details
|
|
.iter()
|
|
.any(|detail| detail.code == warning.code)
|
|
{
|
|
return None;
|
|
}
|
|
Some(InstanceUpgradeCompatibilityWarning {
|
|
code: warning.code,
|
|
relative_path: item.map(|item| item.relative_path.clone()),
|
|
content_id: item
|
|
.map(|item| item.content_id.clone())
|
|
.or_else(|| warning.content_id.clone()),
|
|
provider: warning.provider,
|
|
project_id: warning.project_id.clone(),
|
|
conflicting_project_id: warning.conflicting_project_id.clone(),
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for detail in physical_details {
|
|
if !details.iter().any(|existing| {
|
|
existing.code == detail.code
|
|
&& existing.content_id == detail.content_id
|
|
&& existing.relative_path == detail.relative_path
|
|
}) {
|
|
details.push(detail);
|
|
}
|
|
}
|
|
details
|
|
}
|
|
|
|
fn physical_upgrade_warning_code(
|
|
execution: &InstanceUpgradeExecution,
|
|
item: &crate::state::InstanceUpgradeItem,
|
|
) -> Option<crate::state::InstanceUpgradeIssueCode> {
|
|
use crate::state::{
|
|
InstanceUpgradeAction, InstanceUpgradeIssueCode,
|
|
InstanceUpgradeItemStatus,
|
|
};
|
|
|
|
let action = execution
|
|
.solution
|
|
.selections
|
|
.iter()
|
|
.find(|selection| selection.content_id == item.content_id)
|
|
.map(|selection| selection.action)
|
|
.unwrap_or(item.resolution.action);
|
|
match item.status {
|
|
InstanceUpgradeItemStatus::Unidentified => {
|
|
Some(InstanceUpgradeIssueCode::Unidentified)
|
|
}
|
|
InstanceUpgradeItemStatus::UnsupportedContentType => {
|
|
Some(InstanceUpgradeIssueCode::UnsupportedContentType)
|
|
}
|
|
InstanceUpgradeItemStatus::NoCompatibleRelease
|
|
| InstanceUpgradeItemStatus::UpgradeAvailable
|
|
if action == InstanceUpgradeAction::Keep =>
|
|
{
|
|
Some(InstanceUpgradeIssueCode::KeepIncompatible)
|
|
}
|
|
InstanceUpgradeItemStatus::NoCompatibleShaderRuntime
|
|
if action == InstanceUpgradeAction::Keep =>
|
|
{
|
|
Some(InstanceUpgradeIssueCode::NoCompatibleShaderRuntime)
|
|
}
|
|
InstanceUpgradeItemStatus::ShaderRuntimeMissing
|
|
if action == InstanceUpgradeAction::Keep =>
|
|
{
|
|
Some(InstanceUpgradeIssueCode::ShaderRuntimeMissing)
|
|
}
|
|
InstanceUpgradeItemStatus::ShaderRuntimeUnknown
|
|
if action == InstanceUpgradeAction::Keep =>
|
|
{
|
|
Some(InstanceUpgradeIssueCode::ShaderRuntimeUnknown)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn copy_physical_instance_contents(
|
|
job_id: Uuid,
|
|
job_state: &InstallJobState,
|
|
state: &State,
|
|
source_instance_id: &str,
|
|
target_instance_id: &str,
|
|
) -> crate::Result<()> {
|
|
let source_path = crate::util::io::canonicalize(
|
|
&crate::api::instance::get_full_path(source_instance_id).await?,
|
|
)?;
|
|
crate::api::pack::import::copy_dotminecraft_with_reporter(
|
|
target_instance_id,
|
|
source_path,
|
|
&state.io_semaphore,
|
|
InstallProgressReporter::new(job_id, job_state.clone()),
|
|
InstallPhaseDetails::Empty,
|
|
)
|
|
.await?;
|
|
crate::state::sync_content_files(target_instance_id, state).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn create_upgrade_backup(
|
|
job_id: Uuid,
|
|
job_state: &InstallJobState,
|
|
state: &State,
|
|
source_instance_id: &str,
|
|
backup_name: Option<&str>,
|
|
) -> crate::Result<String> {
|
|
let source = crate::state::get_instance(source_instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown upgrade source".to_string())
|
|
})?;
|
|
let backup = crate::api::instance::create(
|
|
backup_name.map(str::to_string).unwrap_or_else(|| {
|
|
format!("{} (Upgrade Backup)", source.instance.name)
|
|
}),
|
|
source.applied_content_set.game_version.clone(),
|
|
source.applied_content_set.loader,
|
|
source.applied_content_set.loader_version.clone(),
|
|
source.instance.icon_path.clone(),
|
|
InstanceLink::Unmanaged,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
let backup_result = async {
|
|
clone_instance_loader_components(
|
|
&source.loader_components,
|
|
&backup.instance.id,
|
|
state,
|
|
)
|
|
.await?;
|
|
copy_physical_instance_contents(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
source_instance_id,
|
|
&backup.instance.id,
|
|
)
|
|
.await?;
|
|
clone_upgrade_backup_content_metadata(
|
|
source_instance_id,
|
|
&source.applied_content_set.id,
|
|
&backup.instance.id,
|
|
&backup.applied_content_set.id,
|
|
state,
|
|
)
|
|
.await?;
|
|
crate::state::instances::commands::set_instance_install_stage(
|
|
&backup.instance.id,
|
|
InstanceInstallStage::Installed,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
}
|
|
.await;
|
|
if let Err(error) = backup_result {
|
|
return match crate::state::remove_instance(&backup.instance.id, state)
|
|
.await
|
|
{
|
|
Ok(()) => Err(error),
|
|
Err(cleanup_error) => Err(crate::ErrorKind::OtherError(format!(
|
|
"Upgrade backup creation failed: {error}; cleanup also failed: {cleanup_error}"
|
|
))
|
|
.into()),
|
|
};
|
|
}
|
|
Ok(backup.instance.id)
|
|
}
|
|
|
|
async fn clone_upgrade_backup_content_metadata(
|
|
source_instance_id: &str,
|
|
source_content_set_id: &str,
|
|
target_instance_id: &str,
|
|
target_content_set_id: &str,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
use crate::state::instances::adapters::sqlite::content_rows;
|
|
|
|
let source_files =
|
|
content_rows::get_instance_files(source_instance_id, &state.pool)
|
|
.await?;
|
|
let target_files =
|
|
content_rows::get_instance_files(target_instance_id, &state.pool)
|
|
.await?;
|
|
let source_entries =
|
|
content_rows::get_content_entries(source_content_set_id, &state.pool)
|
|
.await?;
|
|
let source_edges = content_rows::get_content_dependency_edges(
|
|
source_content_set_id,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
let dependency_backfilled =
|
|
content_rows::get_dependency_backfilled_entry_ids(
|
|
source_content_set_id,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
let source_paths = source_files
|
|
.iter()
|
|
.map(|file| (file.id.as_str(), file.relative_path.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
let target_file_ids = target_files
|
|
.iter()
|
|
.map(|file| (file.relative_path.as_str(), file.id.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut provider_refs = HashMap::new();
|
|
for entry in &source_entries {
|
|
provider_refs.insert(
|
|
entry.id.as_str(),
|
|
content_rows::get_content_provider_refs_with_origin(
|
|
&entry.id,
|
|
&state.pool,
|
|
)
|
|
.await?,
|
|
);
|
|
}
|
|
|
|
let mut tx = state.pool.begin().await?;
|
|
sqlx::query(
|
|
"DELETE FROM instance_content_dependencies WHERE content_set_id = ?",
|
|
)
|
|
.bind(target_content_set_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let mut entry_ids = HashMap::new();
|
|
for source_entry in &source_entries {
|
|
let target_file_id = match source_entry.file_id.as_deref() {
|
|
Some(source_file_id) => {
|
|
let relative_path = source_paths.get(source_file_id).ok_or_else(
|
|
|| {
|
|
crate::ErrorKind::FSError(format!(
|
|
"Upgrade backup source content entry {} has no file",
|
|
source_entry.id
|
|
))
|
|
},
|
|
)?;
|
|
Some(*target_file_ids.get(relative_path).ok_or_else(|| {
|
|
crate::ErrorKind::FSError(format!(
|
|
"Upgrade backup is missing copied content file {relative_path}"
|
|
))
|
|
})?)
|
|
}
|
|
None => None,
|
|
};
|
|
let target_entry =
|
|
content_rows::upsert_content_entry_from_parts_in_transaction(
|
|
content_rows::UpsertContentEntry {
|
|
instance_id: target_instance_id,
|
|
content_set_id: target_content_set_id,
|
|
file_id: target_file_id,
|
|
project_type: source_entry.project_type,
|
|
source_kind: source_entry.source_kind,
|
|
ownership_kind: source_entry.ownership_kind,
|
|
auto_dependency: source_entry.auto_dependency,
|
|
server_requirement: source_entry.server_requirement,
|
|
client_requirement: source_entry.client_requirement,
|
|
enabled: source_entry.enabled,
|
|
},
|
|
&mut tx,
|
|
)
|
|
.await?;
|
|
sqlx::query(
|
|
"DELETE FROM instance_content_provider_refs WHERE content_entry_id = ?",
|
|
)
|
|
.bind(&target_entry.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
for (provider_ref, origin) in &provider_refs[source_entry.id.as_str()] {
|
|
content_rows::upsert_content_provider_ref_in_transaction(
|
|
&target_entry.id,
|
|
provider_ref,
|
|
*origin,
|
|
&mut tx,
|
|
)
|
|
.await?;
|
|
}
|
|
if dependency_backfilled.contains(&source_entry.id) {
|
|
content_rows::set_content_entry_dependency_backfilled_in_transaction(
|
|
&target_entry.id,
|
|
&mut tx,
|
|
)
|
|
.await?;
|
|
}
|
|
entry_ids.insert(source_entry.id.as_str(), target_entry.id);
|
|
}
|
|
for source_edge in source_edges {
|
|
let parent_entry_id = entry_ids
|
|
.get(source_edge.parent_entry_id.as_str())
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::FSError(format!(
|
|
"Upgrade backup cannot map dependency parent {}",
|
|
source_edge.parent_entry_id
|
|
))
|
|
})?;
|
|
let child_entry_id = entry_ids
|
|
.get(source_edge.child_entry_id.as_str())
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::FSError(format!(
|
|
"Upgrade backup cannot map dependency child {}",
|
|
source_edge.child_entry_id
|
|
))
|
|
})?;
|
|
let now = chrono::Utc::now();
|
|
content_rows::upsert_content_dependency_edge_in_transaction(
|
|
&crate::state::instances::ContentDependencyEdge {
|
|
id: format!("content-dependency:{}", Uuid::new_v4()),
|
|
content_set_id: target_content_set_id.to_string(),
|
|
parent_entry_id: parent_entry_id.clone(),
|
|
child_entry_id: child_entry_id.clone(),
|
|
created_at: now,
|
|
modified_at: now,
|
|
..source_edge
|
|
},
|
|
&mut tx,
|
|
)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn clone_instance_loader_components(
|
|
components: &[LoaderComponent],
|
|
target_instance_id: &str,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let components = components
|
|
.iter()
|
|
.cloned()
|
|
.map(|mut component| {
|
|
component.instance_id = target_instance_id.to_string();
|
|
component
|
|
})
|
|
.collect::<Vec<_>>();
|
|
crate::state::instances::commands::replace_instance_loader_components(
|
|
target_instance_id,
|
|
&components,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn stage_upgrade_content(
|
|
instance_id: &str,
|
|
execution: &InstanceUpgradeExecution,
|
|
reporter: Option<InstallProgressReporter>,
|
|
state: &State,
|
|
) -> crate::Result<Vec<StagedUpgradeMutation>> {
|
|
let metadata = crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown upgrade target".to_string())
|
|
})?;
|
|
let entries = crate::state::instances::adapters::sqlite::content_rows::get_content_entries(
|
|
&metadata.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
let files = crate::state::instances::adapters::sqlite::content_rows::get_instance_files(
|
|
instance_id,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
let files_by_id = files
|
|
.iter()
|
|
.map(|file| (file.id.as_str(), file.relative_path.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
let entries_by_path = entries
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
Some((
|
|
files_by_id.get(entry.file_id.as_deref()?)?.to_string(),
|
|
entry,
|
|
))
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
let item_paths = execution
|
|
.items
|
|
.iter()
|
|
.map(|item| (item.content_id.as_str(), item.relative_path.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut requests = Vec::<(
|
|
Option<String>,
|
|
ContentProvider,
|
|
String,
|
|
String,
|
|
bool,
|
|
bool,
|
|
)>::new();
|
|
for selection in &execution.solution.selections {
|
|
if selection.action != InstanceUpgradeAction::Upgrade {
|
|
continue;
|
|
}
|
|
let Some(provider) = selection.provider else {
|
|
continue;
|
|
};
|
|
let project_id = selection.project_id.clone().ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Upgrade selection {} has no project id",
|
|
selection.content_id
|
|
))
|
|
})?;
|
|
let target_release_id =
|
|
selection.target_release_id.clone().ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Upgrade selection {} has no target release",
|
|
selection.content_id
|
|
))
|
|
})?;
|
|
requests.push((
|
|
Some(selection.content_id.clone()),
|
|
provider,
|
|
project_id,
|
|
target_release_id,
|
|
false,
|
|
selection.enabled,
|
|
));
|
|
}
|
|
for change in &execution.solution.dependency_changes {
|
|
if !matches!(
|
|
change.kind,
|
|
InstanceUpgradeDependencyChangeKind::Add
|
|
| InstanceUpgradeDependencyChangeKind::Upgrade
|
|
) {
|
|
continue;
|
|
}
|
|
requests.push((
|
|
change.existing_content_id.clone(),
|
|
change.provider,
|
|
change.project_id.clone(),
|
|
change.target_release_id.clone().ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Dependency {} has no target release",
|
|
change.project_id
|
|
))
|
|
})?,
|
|
true,
|
|
change.enabled,
|
|
));
|
|
}
|
|
|
|
let mut seen = HashSet::new();
|
|
requests.retain(|(content_id, provider, project_id, release_id, ..)| {
|
|
seen.insert(format!(
|
|
"{}:{}:{}:{}",
|
|
content_id.as_deref().unwrap_or("new"),
|
|
provider.as_str(),
|
|
project_id,
|
|
release_id
|
|
))
|
|
});
|
|
let contexts = requests
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(
|
|
|(
|
|
index,
|
|
(
|
|
content_id,
|
|
provider,
|
|
project_id,
|
|
release_id,
|
|
auto_dependency,
|
|
enabled,
|
|
),
|
|
)| {
|
|
let source_path = content_id
|
|
.as_deref()
|
|
.and_then(|content_id| item_paths.get(content_id).copied());
|
|
let existing_entry = content_id
|
|
.as_deref()
|
|
.and_then(|content_id| {
|
|
entries.iter().find(|entry| entry.id == content_id)
|
|
})
|
|
.or_else(|| {
|
|
source_path.and_then(|path| entries_by_path.get(path).copied())
|
|
});
|
|
let existing_path = existing_entry
|
|
.and_then(|entry| entry.file_id.as_deref())
|
|
.and_then(|file_id| files_by_id.get(file_id).copied())
|
|
.map(ToString::to_string)
|
|
.or_else(|| source_path.map(ToString::to_string));
|
|
let ownership = existing_entry
|
|
.map(|entry| entry.ownership_kind)
|
|
.unwrap_or(
|
|
crate::state::instances::ContentOwnershipKind::UserAdded,
|
|
);
|
|
let project_type = existing_entry
|
|
.map(|entry| entry.project_type)
|
|
.or_else(|| {
|
|
source_path.and_then(
|
|
crate::state::instances::adapters::filesystem::project_type_from_relative_path,
|
|
)
|
|
});
|
|
UpgradeStagingRequest {
|
|
index,
|
|
provider,
|
|
project_id,
|
|
release_id,
|
|
auto_dependency,
|
|
enabled,
|
|
existing_path,
|
|
ownership,
|
|
project_type,
|
|
}
|
|
},
|
|
)
|
|
.collect::<Vec<_>>();
|
|
if let Some(reporter) = reporter.as_ref() {
|
|
reporter
|
|
.record_events(vec![InstallJobEventKind::ContentDownloadStarted {
|
|
files: contexts.len() as u64,
|
|
bytes: None,
|
|
}])
|
|
.await?;
|
|
}
|
|
let mut downloads = contexts
|
|
.into_iter()
|
|
.map(|context| {
|
|
let reporter = reporter.clone();
|
|
async move {
|
|
let index = context.index;
|
|
let mutation = stage_one_upgrade_request(
|
|
instance_id,
|
|
context,
|
|
reporter,
|
|
state,
|
|
)
|
|
.await?;
|
|
Ok::<_, crate::Error>((index, mutation))
|
|
}
|
|
})
|
|
.collect::<FuturesUnordered<_>>();
|
|
collect_ordered_upgrade_staging(&mut downloads).await
|
|
}
|
|
|
|
async fn collect_ordered_upgrade_staging<F, T>(
|
|
downloads: &mut FuturesUnordered<F>,
|
|
) -> crate::Result<Vec<T>>
|
|
where
|
|
F: Future<Output = crate::Result<(usize, T)>>,
|
|
{
|
|
let mut staged = Vec::with_capacity(downloads.len());
|
|
while let Some(result) = downloads.next().await {
|
|
staged.push(result?);
|
|
}
|
|
staged.sort_by_key(|(index, _)| *index);
|
|
Ok(staged.into_iter().map(|(_, mutation)| mutation).collect())
|
|
}
|
|
|
|
async fn stage_one_upgrade_request(
|
|
instance_id: &str,
|
|
context: UpgradeStagingRequest,
|
|
reporter: Option<InstallProgressReporter>,
|
|
state: &State,
|
|
) -> crate::Result<StagedUpgradeMutation> {
|
|
let download = match context.provider {
|
|
ContentProvider::Modrinth => StagedUpgradeDownload::Modrinth(
|
|
match reporter.as_ref() {
|
|
Some(reporter) => crate::state::instances::commands::download_project_version_with_reporter(
|
|
instance_id,
|
|
&context.release_id,
|
|
if context.auto_dependency {
|
|
DownloadReason::Dependency
|
|
} else {
|
|
DownloadReason::Update
|
|
},
|
|
None,
|
|
reporter.clone(),
|
|
state,
|
|
)
|
|
.await?,
|
|
None => crate::state::instances::commands::download_project_version(
|
|
instance_id,
|
|
&context.release_id,
|
|
if context.auto_dependency {
|
|
DownloadReason::Dependency
|
|
} else {
|
|
DownloadReason::Update
|
|
},
|
|
None,
|
|
state,
|
|
)
|
|
.await?,
|
|
},
|
|
),
|
|
ContentProvider::CurseForge => {
|
|
let project_id = context.project_id.parse::<u32>().map_err(|_| {
|
|
crate::ErrorKind::InputError(
|
|
"CurseForge project id is invalid".to_string(),
|
|
)
|
|
})?;
|
|
let file_id = context.release_id.parse::<u32>().map_err(|_| {
|
|
crate::ErrorKind::InputError(
|
|
"CurseForge file id is invalid".to_string(),
|
|
)
|
|
})?;
|
|
StagedUpgradeDownload::CurseForge(
|
|
crate::api::curseforge::stage_curseforge_upgrade_file(
|
|
project_id,
|
|
file_id,
|
|
context.project_type,
|
|
reporter.as_ref(),
|
|
)
|
|
.await?,
|
|
)
|
|
}
|
|
ContentProvider::McArchive => {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"MCArchive content cannot be downloaded by upgrade execution"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
ContentProvider::Local => {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Local-only content cannot be downloaded by upgrade execution"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
};
|
|
let target_path =
|
|
context
|
|
.existing_path
|
|
.clone()
|
|
.unwrap_or_else(|| match &download {
|
|
StagedUpgradeDownload::Modrinth(download) => format!(
|
|
"{}/{}",
|
|
download.project_type.get_folder(),
|
|
download.file_name
|
|
),
|
|
StagedUpgradeDownload::CurseForge(download) => format!(
|
|
"{}/{}",
|
|
download.project_type.get_folder(),
|
|
download.file.file_name
|
|
),
|
|
});
|
|
let download_size = match &download {
|
|
StagedUpgradeDownload::Modrinth(download) => download.size,
|
|
StagedUpgradeDownload::CurseForge(download) => {
|
|
download.file.file_length
|
|
}
|
|
};
|
|
if let Some(reporter) = reporter {
|
|
reporter
|
|
.record_events(vec![InstallJobEventKind::ContentFileCompleted {
|
|
path: target_path.clone(),
|
|
bytes: download_size,
|
|
}])
|
|
.await?;
|
|
}
|
|
Ok(StagedUpgradeMutation {
|
|
existing_path: context.existing_path,
|
|
target_path,
|
|
ownership: context.ownership,
|
|
auto_dependency: context.auto_dependency,
|
|
enabled: context.enabled,
|
|
download,
|
|
})
|
|
}
|
|
|
|
async fn apply_upgrade_content(
|
|
instance_id: &str,
|
|
staged: Vec<StagedUpgradeMutation>,
|
|
execution: &InstanceUpgradeExecution,
|
|
external_paths: &HashSet<String>,
|
|
source_files: &[crate::state::InstanceUpgradeSourceFile],
|
|
state: &State,
|
|
) -> crate::Result<AppliedUpgradeContent> {
|
|
let mut skipped = Vec::new();
|
|
let mut launcher_expected_files = HashMap::new();
|
|
#[cfg(debug_assertions)]
|
|
let fail_after_mutations = injected_upgrade_failure_after_mutations();
|
|
#[cfg(debug_assertions)]
|
|
tracing::warn!(
|
|
raw_env = ?std::env::var("AXOLOTL_TEST_UPGRADE_FAIL_AFTER_MUTATIONS"),
|
|
parsed = ?fail_after_mutations,
|
|
"T11 upgrade fault injection enabled"
|
|
);
|
|
#[cfg(not(debug_assertions))]
|
|
tracing::warn!(
|
|
"T11 upgrade fault injection unavailable: debug_assertions=false"
|
|
);
|
|
#[cfg(debug_assertions)]
|
|
let pause_after_mutations = injected_upgrade_pause_after_mutations();
|
|
#[cfg(debug_assertions)]
|
|
tracing::warn!(
|
|
raw_env = ?std::env::var("AXOLOTL_TEST_UPGRADE_PAUSE_AFTER_MUTATIONS"),
|
|
parsed = ?pause_after_mutations,
|
|
"T12 upgrade crash-test pause configured"
|
|
);
|
|
#[cfg(debug_assertions)]
|
|
let mut completed_mutations = 0_usize;
|
|
for mutation in staged {
|
|
let changed_after_staging = match mutation.existing_path.as_deref() {
|
|
Some(path) => {
|
|
source_file_changed(path, source_files, instance_id).await?
|
|
}
|
|
None => false,
|
|
};
|
|
if upgrade_mutation_conflicts(&mutation, external_paths)
|
|
|| changed_after_staging
|
|
{
|
|
skipped.push(mutation.target_path);
|
|
continue;
|
|
}
|
|
let relative_path = match mutation.download {
|
|
StagedUpgradeDownload::Modrinth(download) => {
|
|
crate::state::instances::commands::apply_downloaded_project_version_at_path(
|
|
instance_id,
|
|
&mutation.target_path,
|
|
download,
|
|
crate::state::instances::ContentSourceKind::Local,
|
|
mutation.ownership,
|
|
state,
|
|
)
|
|
.await?
|
|
}
|
|
StagedUpgradeDownload::CurseForge(download) => {
|
|
crate::api::curseforge::apply_staged_curseforge_upgrade_file(
|
|
instance_id,
|
|
download,
|
|
mutation.ownership,
|
|
&mutation.target_path,
|
|
)
|
|
.await?
|
|
}
|
|
};
|
|
let scope = crate::state::instances::commands::resolve_content_scope(
|
|
instance_id,
|
|
None,
|
|
state,
|
|
)
|
|
.await?;
|
|
let mut final_relative_path = relative_path.clone();
|
|
if let Some(entry) = crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_relative_path(
|
|
&scope.content_set_id,
|
|
&relative_path,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
{
|
|
if mutation.auto_dependency {
|
|
crate::state::instances::adapters::sqlite::content_rows::set_content_entry_auto_dependency(
|
|
&entry.id,
|
|
true,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
}
|
|
if entry.enabled != mutation.enabled {
|
|
let toggled = crate::state::instances::commands::toggle_content_entries(
|
|
instance_id,
|
|
&[entry.id],
|
|
Some(mutation.enabled),
|
|
state,
|
|
)
|
|
.await?;
|
|
if let Some(toggled) = toggled.first() {
|
|
final_relative_path = toggled.path.clone();
|
|
}
|
|
}
|
|
}
|
|
record_launcher_expected_file(
|
|
instance_id,
|
|
&relative_path,
|
|
&final_relative_path,
|
|
&mut launcher_expected_files,
|
|
)
|
|
.await?;
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
completed_mutations += 1;
|
|
if fail_after_mutations == Some(completed_mutations) {
|
|
return Err(crate::ErrorKind::InputError(format!(
|
|
"Injected unmanaged instance upgrade failure after {completed_mutations} mutation(s)"
|
|
))
|
|
.into());
|
|
}
|
|
}
|
|
#[cfg(debug_assertions)]
|
|
if pause_after_mutations == Some(completed_mutations) {
|
|
tracing::warn!(
|
|
"T12 upgrade crash-test pause after {completed_mutations} mutation(s); terminate process now"
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
|
}
|
|
}
|
|
|
|
let item_paths = execution
|
|
.items
|
|
.iter()
|
|
.map(|item| (item.content_id.as_str(), item.relative_path.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
for item in &execution.items {
|
|
let path = item.relative_path.as_str();
|
|
if external_paths.contains(path)
|
|
|| source_file_changed(path, source_files, instance_id).await?
|
|
{
|
|
continue;
|
|
}
|
|
let (_, desired) = execution.final_physical_decision(item);
|
|
let final_path =
|
|
set_upgrade_path_enabled(instance_id, path, desired, state).await?;
|
|
record_launcher_expected_file(
|
|
instance_id,
|
|
path,
|
|
&final_path,
|
|
&mut launcher_expected_files,
|
|
)
|
|
.await?;
|
|
}
|
|
for change in &execution.solution.dependency_changes {
|
|
let Some(content_id) = change.existing_content_id.as_deref() else {
|
|
continue;
|
|
};
|
|
let Some(path) = item_paths.get(content_id).copied() else {
|
|
continue;
|
|
};
|
|
if external_paths.contains(path)
|
|
|| source_file_changed(path, source_files, instance_id).await?
|
|
{
|
|
if change.kind == InstanceUpgradeDependencyChangeKind::Remove
|
|
&& !skipped.iter().any(|skipped_path| skipped_path == path)
|
|
{
|
|
skipped.push(path.to_string());
|
|
}
|
|
continue;
|
|
}
|
|
match change.kind {
|
|
InstanceUpgradeDependencyChangeKind::Remove => {
|
|
crate::state::instances::commands::remove_project(
|
|
instance_id,
|
|
path,
|
|
state,
|
|
)
|
|
.await?;
|
|
launcher_expected_files.insert(path.to_string(), None);
|
|
}
|
|
InstanceUpgradeDependencyChangeKind::Keep => {
|
|
let final_path = set_upgrade_path_enabled(
|
|
instance_id,
|
|
path,
|
|
change.enabled,
|
|
state,
|
|
)
|
|
.await?;
|
|
record_launcher_expected_file(
|
|
instance_id,
|
|
path,
|
|
&final_path,
|
|
&mut launcher_expected_files,
|
|
)
|
|
.await?;
|
|
}
|
|
InstanceUpgradeDependencyChangeKind::Add
|
|
| InstanceUpgradeDependencyChangeKind::Upgrade => {}
|
|
}
|
|
}
|
|
Ok(AppliedUpgradeContent {
|
|
skipped,
|
|
launcher_expected_files,
|
|
})
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
fn injected_upgrade_failure_after_mutations() -> Option<usize> {
|
|
std::env::var("AXOLOTL_TEST_UPGRADE_FAIL_AFTER_MUTATIONS")
|
|
.ok()
|
|
.and_then(|value| debug_mutation_count(&value))
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
fn injected_upgrade_pause_after_mutations() -> Option<usize> {
|
|
std::env::var("AXOLOTL_TEST_UPGRADE_PAUSE_AFTER_MUTATIONS")
|
|
.ok()
|
|
.and_then(|value| debug_mutation_count(&value))
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
fn debug_mutation_count(value: &str) -> Option<usize> {
|
|
value.trim().parse().ok().filter(|count| *count > 0)
|
|
}
|
|
|
|
async fn set_upgrade_path_enabled(
|
|
instance_id: &str,
|
|
relative_path: &str,
|
|
enabled: bool,
|
|
state: &State,
|
|
) -> crate::Result<String> {
|
|
let scope = crate::state::instances::commands::resolve_content_scope(
|
|
instance_id,
|
|
None,
|
|
state,
|
|
)
|
|
.await?;
|
|
if let Some(entry) = crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_relative_path(
|
|
&scope.content_set_id,
|
|
relative_path,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
{
|
|
if entry.enabled != enabled {
|
|
let toggled = crate::state::instances::commands::toggle_content_entries(
|
|
instance_id,
|
|
&[entry.id],
|
|
Some(enabled),
|
|
state,
|
|
)
|
|
.await?;
|
|
if let Some(toggled) = toggled.first() {
|
|
return Ok(toggled.path.clone());
|
|
}
|
|
}
|
|
return Ok(relative_path.to_string());
|
|
}
|
|
crate::state::instances::commands::toggle_disable_project(
|
|
instance_id,
|
|
relative_path,
|
|
Some(enabled),
|
|
state,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn collect_upgrade_external_changes(
|
|
instance_id: &str,
|
|
source_files: &[crate::state::InstanceUpgradeSourceFile],
|
|
baseline: Option<&super::model::InstanceUpgradeWatchBaseline>,
|
|
state: &State,
|
|
) -> crate::Result<Vec<InstanceUpgradeExternalChange>> {
|
|
let current = state.file_watcher.content_watch_snapshot(instance_id).await;
|
|
let requires_full_scan = match (baseline, current.as_ref()) {
|
|
(Some(baseline), Some(current)) => {
|
|
baseline.epoch != current.epoch
|
|
|| current.generation > baseline.generation
|
|
}
|
|
_ => true,
|
|
};
|
|
if requires_full_scan {
|
|
let current_files = crate::state::instances::commands::scan_instance_upgrade_source_files(
|
|
instance_id,
|
|
state,
|
|
)
|
|
.await?;
|
|
return Ok(diff_upgrade_source_files(source_files, ¤t_files));
|
|
}
|
|
let dirty_paths = match (baseline, current.as_ref()) {
|
|
(Some(baseline), Some(current)) if baseline.epoch == current.epoch => {
|
|
let baseline_paths = baseline
|
|
.dirty_paths
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<HashSet<_>>();
|
|
current
|
|
.dirty_paths
|
|
.iter()
|
|
.filter(|path| !baseline_paths.contains(path.as_str()))
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
}
|
|
_ => Vec::new(),
|
|
};
|
|
let source_by_path = source_files
|
|
.iter()
|
|
.map(|file| (file.relative_path.as_str(), file))
|
|
.collect::<HashMap<_, _>>();
|
|
let base = crate::api::instance::get_full_path(instance_id).await?;
|
|
let mut changes = Vec::new();
|
|
for relative_path in dirty_paths {
|
|
let path = match recovery::checked_instance_path(&base, &relative_path)
|
|
{
|
|
Ok(path) => path,
|
|
Err(_) => {
|
|
changes.push(InstanceUpgradeExternalChange {
|
|
relative_path,
|
|
kind: InstanceUpgradeExternalChangeKind::Modified,
|
|
});
|
|
continue;
|
|
}
|
|
};
|
|
let exists = tokio::fs::symlink_metadata(&path).await.is_ok();
|
|
let kind = classify_upgrade_external_change(
|
|
source_by_path.contains_key(relative_path.as_str()),
|
|
exists,
|
|
);
|
|
changes.push(InstanceUpgradeExternalChange {
|
|
relative_path,
|
|
kind,
|
|
});
|
|
}
|
|
Ok(changes)
|
|
}
|
|
|
|
fn diff_upgrade_source_files(
|
|
source_files: &[crate::state::InstanceUpgradeSourceFile],
|
|
current_files: &[crate::state::InstanceUpgradeSourceFile],
|
|
) -> Vec<InstanceUpgradeExternalChange> {
|
|
let source = source_files
|
|
.iter()
|
|
.map(|file| (file.relative_path.as_str(), file))
|
|
.collect::<HashMap<_, _>>();
|
|
let current = current_files
|
|
.iter()
|
|
.map(|file| (file.relative_path.as_str(), file))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut paths = source
|
|
.keys()
|
|
.chain(current.keys())
|
|
.copied()
|
|
.collect::<Vec<_>>();
|
|
paths.sort_unstable();
|
|
paths.dedup();
|
|
paths
|
|
.into_iter()
|
|
.filter_map(|path| match (source.get(path), current.get(path)) {
|
|
(None, Some(_)) => Some(InstanceUpgradeExternalChange {
|
|
relative_path: path.to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Added,
|
|
}),
|
|
(Some(_), None) => Some(InstanceUpgradeExternalChange {
|
|
relative_path: path.to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Removed,
|
|
}),
|
|
(Some(source), Some(current))
|
|
if source.sha1 != current.sha1
|
|
|| source.size != current.size
|
|
|| source.enabled != current.enabled =>
|
|
{
|
|
Some(InstanceUpgradeExternalChange {
|
|
relative_path: path.to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Modified,
|
|
})
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn collect_unsafe_upgrade_paths(
|
|
instance_id: &str,
|
|
) -> crate::Result<Vec<String>> {
|
|
let base = crate::api::instance::get_full_path(instance_id).await?;
|
|
let resolved_base = crate::util::io::canonicalize(&base)?;
|
|
let mut unsafe_paths = Vec::new();
|
|
for path in
|
|
crate::api::pack::import::get_all_subfiles(&resolved_base, false)
|
|
.await?
|
|
{
|
|
let metadata = tokio::fs::symlink_metadata(&path).await?;
|
|
if !crate::util::io::is_symlink_or_reparse(&metadata) {
|
|
continue;
|
|
}
|
|
let Ok(relative) = path.strip_prefix(&resolved_base) else {
|
|
continue;
|
|
};
|
|
let relative = relative.to_string_lossy().replace('\\', "/");
|
|
if matches!(
|
|
relative.split('/').next(),
|
|
Some("mods" | "resourcepacks" | "shaderpacks" | "datapacks")
|
|
) {
|
|
unsafe_paths.push(relative);
|
|
}
|
|
}
|
|
unsafe_paths.sort_unstable();
|
|
unsafe_paths.dedup();
|
|
Ok(unsafe_paths)
|
|
}
|
|
|
|
async fn source_file_changed(
|
|
relative_path: &str,
|
|
source_files: &[crate::state::InstanceUpgradeSourceFile],
|
|
instance_id: &str,
|
|
) -> crate::Result<bool> {
|
|
let Some(expected) = source_files
|
|
.iter()
|
|
.find(|file| file.relative_path == relative_path)
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
let base = crate::api::instance::get_full_path(instance_id).await?;
|
|
let path = match recovery::checked_instance_path(&base, relative_path) {
|
|
Ok(path) => path,
|
|
Err(_) => return Ok(true),
|
|
};
|
|
let metadata = match tokio::fs::symlink_metadata(&path).await {
|
|
Ok(metadata) => metadata,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(true);
|
|
}
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
if !metadata.is_file()
|
|
|| crate::util::io::is_symlink_or_reparse(&metadata)
|
|
|| metadata.len() != expected.size
|
|
{
|
|
return Ok(true);
|
|
}
|
|
let (_, sha1) = crate::util::fetch::sha1_file_async(&path).await?;
|
|
Ok(sha1 != expected.sha1)
|
|
}
|
|
|
|
async fn record_launcher_expected_file(
|
|
instance_id: &str,
|
|
original_path: &str,
|
|
final_path: &str,
|
|
expected: &mut HashMap<
|
|
String,
|
|
Option<crate::state::InstanceUpgradeSourceFile>,
|
|
>,
|
|
) -> crate::Result<()> {
|
|
if original_path != final_path {
|
|
expected.insert(original_path.to_string(), None);
|
|
}
|
|
expected.insert(
|
|
final_path.to_string(),
|
|
current_upgrade_source_file(instance_id, final_path).await?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn current_upgrade_source_file(
|
|
instance_id: &str,
|
|
relative_path: &str,
|
|
) -> crate::Result<Option<crate::state::InstanceUpgradeSourceFile>> {
|
|
let base = crate::api::instance::get_full_path(instance_id).await?;
|
|
let path = recovery::checked_instance_path(&base, relative_path)?;
|
|
let metadata = match tokio::fs::symlink_metadata(&path).await {
|
|
Ok(metadata) => metadata,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(None);
|
|
}
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
if !metadata.is_file() || crate::util::io::is_symlink_or_reparse(&metadata)
|
|
{
|
|
return Ok(None);
|
|
}
|
|
let (_, sha1) = crate::util::fetch::sha1_file_async(&path).await?;
|
|
Ok(Some(crate::state::InstanceUpgradeSourceFile {
|
|
relative_path: relative_path.to_string(),
|
|
sha1,
|
|
size: metadata.len(),
|
|
enabled: !relative_path.ends_with(".disabled"),
|
|
}))
|
|
}
|
|
|
|
fn final_upgrade_external_changes(
|
|
source_files: &[crate::state::InstanceUpgradeSourceFile],
|
|
current_files: &[crate::state::InstanceUpgradeSourceFile],
|
|
launcher_expected_files: &HashMap<
|
|
String,
|
|
Option<crate::state::InstanceUpgradeSourceFile>,
|
|
>,
|
|
) -> Vec<InstanceUpgradeExternalChange> {
|
|
diff_upgrade_source_files(source_files, current_files)
|
|
.into_iter()
|
|
.filter(|change| {
|
|
let Some(expected) =
|
|
launcher_expected_files.get(&change.relative_path)
|
|
else {
|
|
return true;
|
|
};
|
|
let current = current_files
|
|
.iter()
|
|
.find(|file| file.relative_path == change.relative_path);
|
|
match (expected.as_ref(), current) {
|
|
(None, None) => false,
|
|
(Some(expected), Some(current)) => expected != current,
|
|
_ => true,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn merge_upgrade_external_changes(
|
|
changes: &mut Vec<InstanceUpgradeExternalChange>,
|
|
additional: Vec<InstanceUpgradeExternalChange>,
|
|
) {
|
|
for change in additional {
|
|
if let Some(existing) = changes
|
|
.iter_mut()
|
|
.find(|existing| existing.relative_path == change.relative_path)
|
|
{
|
|
existing.kind = change.kind;
|
|
} else {
|
|
changes.push(change);
|
|
}
|
|
}
|
|
changes.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
|
|
}
|
|
|
|
fn should_create_upgrade_backup(
|
|
requested: bool,
|
|
mode: SharedUpgradeMode,
|
|
) -> bool {
|
|
requested && mode == SharedUpgradeMode::Direct
|
|
}
|
|
|
|
fn upgrade_mutation_conflicts(
|
|
mutation: &StagedUpgradeMutation,
|
|
external_paths: &HashSet<String>,
|
|
) -> bool {
|
|
external_paths.contains(&mutation.target_path)
|
|
|| mutation
|
|
.existing_path
|
|
.as_ref()
|
|
.is_some_and(|path| external_paths.contains(path))
|
|
}
|
|
|
|
fn classify_upgrade_external_change(
|
|
existed_at_start: bool,
|
|
exists_now: bool,
|
|
) -> InstanceUpgradeExternalChangeKind {
|
|
match (existed_at_start, exists_now) {
|
|
(false, true) => InstanceUpgradeExternalChangeKind::Added,
|
|
(true, false) => InstanceUpgradeExternalChangeKind::Removed,
|
|
_ => InstanceUpgradeExternalChangeKind::Modified,
|
|
}
|
|
}
|
|
|
|
async fn remove_existing_pack_content(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
instance_id: &str,
|
|
) -> crate::Result<HashSet<String>> {
|
|
let metadata = crate::state::instances::commands::get_instance_metadata(
|
|
instance_id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
let (project_id, version_id) = match &metadata.link {
|
|
InstanceLink::ModrinthModpack {
|
|
project_id,
|
|
version_id,
|
|
} => (project_id.clone(), version_id.clone()),
|
|
InstanceLink::ServerProjectModpack {
|
|
content_project_id,
|
|
content_version_id,
|
|
..
|
|
} => (content_project_id.clone(), content_version_id.clone()),
|
|
InstanceLink::ImportedModpack { .. } => {
|
|
recovery::prepare_existing_content_rollback(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
Vec::new(),
|
|
)
|
|
.await?;
|
|
return Ok(HashSet::new());
|
|
}
|
|
_ => return Ok(HashSet::new()),
|
|
};
|
|
|
|
let disabled_project_ids =
|
|
crate::state::instances::commands::list_project_files(
|
|
instance_id,
|
|
state,
|
|
)
|
|
.await?
|
|
.into_iter()
|
|
.filter_map(|file| {
|
|
(!file.enabled).then(|| {
|
|
file.provider_refs
|
|
.iter()
|
|
.find_map(|provider| match provider {
|
|
ContentProviderRef::Modrinth { project_id, .. } => {
|
|
Some(project_id.to_string())
|
|
}
|
|
ContentProviderRef::CurseForge { .. } => None,
|
|
ContentProviderRef::McArchive { .. } => None,
|
|
})
|
|
})?
|
|
})
|
|
.collect::<HashSet<_>>();
|
|
let reporter = InstallProgressReporter::new(job_id, job_state.clone());
|
|
let old_pack = generate_pack_from_version_id_with_reporter(
|
|
project_id.clone(),
|
|
version_id.clone(),
|
|
metadata.instance.name.clone(),
|
|
None,
|
|
instance_id.to_string(),
|
|
DownloadReason::Update,
|
|
reporter,
|
|
)
|
|
.await?;
|
|
|
|
let related_paths = related_file_paths(&old_pack.file).await?;
|
|
recovery::prepare_existing_content_rollback(
|
|
job_id,
|
|
job_state,
|
|
state,
|
|
related_paths,
|
|
)
|
|
.await?;
|
|
|
|
Ok(disabled_project_ids)
|
|
}
|
|
|
|
async fn restore_disabled_projects(
|
|
instance_id: &str,
|
|
disabled_project_ids: HashSet<String>,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
if disabled_project_ids.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
for file in crate::state::instances::commands::list_project_files(
|
|
instance_id,
|
|
state,
|
|
)
|
|
.await?
|
|
{
|
|
let is_disabled_modrinth_project = file.provider_refs.iter().any(
|
|
|provider| {
|
|
matches!(
|
|
provider,
|
|
ContentProviderRef::Modrinth { project_id, .. }
|
|
if disabled_project_ids.contains(&project_id.to_string())
|
|
)
|
|
},
|
|
);
|
|
if file.enabled && is_disabled_modrinth_project {
|
|
crate::state::instances::commands::toggle_disable_project(
|
|
instance_id,
|
|
&file.relative_path,
|
|
Some(false),
|
|
state,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn install_pack(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
location: CreatePackLocation,
|
|
instance_id: String,
|
|
reason: DownloadReason,
|
|
) -> crate::Result<InstallExecutionOutcome<()>> {
|
|
let reporter = InstallProgressReporter::new(job_id, job_state.clone());
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingPackFile,
|
|
None,
|
|
modpack_details(&location),
|
|
)
|
|
.await?;
|
|
|
|
let create_pack = match location {
|
|
CreatePackLocation::FromVersionId {
|
|
project_id,
|
|
version_id,
|
|
title,
|
|
icon_url,
|
|
} => {
|
|
reporter
|
|
.set_context(
|
|
InstallErrorContext::new("download modpack file")
|
|
.project_id(project_id.clone())
|
|
.version_id(version_id.clone())
|
|
.build(),
|
|
)
|
|
.await?;
|
|
generate_pack_from_version_id_with_reporter(
|
|
project_id,
|
|
version_id,
|
|
title,
|
|
icon_url,
|
|
instance_id.clone(),
|
|
reason,
|
|
reporter.clone(),
|
|
)
|
|
.await?
|
|
}
|
|
CreatePackLocation::FromFile { path } => {
|
|
reporter
|
|
.set_context(
|
|
InstallErrorContext::new("read local modpack file")
|
|
.source_path(path.display().to_string())
|
|
.build(),
|
|
)
|
|
.await?;
|
|
match crate::api::pack::detect::detect_local_pack(&path).await {
|
|
Ok(detected) => {
|
|
if detected.format
|
|
!= crate::api::pack::detect::LocalPackFormat::Mrpack
|
|
{
|
|
// Non-mrpack format — dispatch to format-specific
|
|
// installer via install_local_pack_file.
|
|
return install_local_pack_file(
|
|
detected,
|
|
path,
|
|
instance_id,
|
|
reporter,
|
|
)
|
|
.await;
|
|
}
|
|
// Mrpack — fall through to standard mrpack install.
|
|
generate_pack_from_file(path, instance_id.clone()).await?
|
|
}
|
|
Err(detect_error) => {
|
|
// No format recognised — try recursive extraction
|
|
// (3-level deep search for sub-archives, bundled
|
|
// packs, etc.) before giving up.
|
|
tracing::debug!(
|
|
"Local pack format detection failed, trying recursive extraction: {detect_error}"
|
|
);
|
|
return install_local_pack_file_recursive(
|
|
path,
|
|
instance_id,
|
|
reporter,
|
|
0,
|
|
3,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let outcome = install_zipped_mrpack_files_with_reporter(
|
|
create_pack,
|
|
false,
|
|
reason,
|
|
reporter,
|
|
)
|
|
.await?;
|
|
Ok(match outcome {
|
|
MrpackInstallOutcome::Completed(_) => {
|
|
InstallExecutionOutcome::Completed(())
|
|
}
|
|
MrpackInstallOutcome::WaitingForUser(reason) => {
|
|
InstallExecutionOutcome::WaitingForUser(reason)
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Recursively tries to detect and install a modpack, up to max_depth levels.
|
|
#[async_recursion::async_recursion]
|
|
async fn install_local_pack_file_recursive(
|
|
path: PathBuf,
|
|
instance_id: String,
|
|
reporter: InstallProgressReporter,
|
|
current_depth: usize,
|
|
max_depth: usize,
|
|
) -> crate::Result<InstallExecutionOutcome<()>> {
|
|
// First try standard detection - this will already include our InstanceFolder fallback
|
|
if let Ok(detected) =
|
|
crate::api::pack::detect::detect_local_pack(&path).await
|
|
{
|
|
// If it's a standard format (including InstanceFolder), just install it
|
|
return install_local_pack_file(detected, path, instance_id, reporter)
|
|
.await;
|
|
}
|
|
|
|
// If standard detection failed and we're not at max depth, try to look for
|
|
// sub-compressed files to extract and check
|
|
if current_depth < max_depth {
|
|
// Report progress: scanning/extracting phase (high-latency operation)
|
|
let filename = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "archive".to_string());
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::ResolvingPack,
|
|
None,
|
|
InstallPhaseDetails::Modpack {
|
|
project_id: None,
|
|
version_id: None,
|
|
title: Some(format!("Scanning {filename} (level {current_depth}/{max_depth})")),
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let state = State::get().await?;
|
|
let scratch =
|
|
crate::api::pack::archive_util::create_import_scratch_dir(&state)
|
|
.await?;
|
|
|
|
// Extract the entire archive to check for sub-packs
|
|
// First, let's list all entries to find potential sub-compressed files
|
|
let file = std::fs::File::open(&path)?;
|
|
let mut archive = match zip::ZipArchive::new(file) {
|
|
Ok(a) => a,
|
|
Err(_) => {
|
|
// Not a valid zip, can't proceed further
|
|
let _ = tokio::fs::remove_dir_all(&scratch).await;
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Unrecognized modpack format: no known pack manifest was found in the archive".to_string()
|
|
).into());
|
|
}
|
|
};
|
|
|
|
let mut sub_archive_paths = Vec::new();
|
|
|
|
// Collect all potential sub-archive files
|
|
for i in 0..archive.len() {
|
|
let lower_name = {
|
|
let entry = archive
|
|
.by_index_raw(i)
|
|
.map_err(|e| ErrorKind::OtherError(e.to_string()))?;
|
|
let name = crate::api::pack::detect::decode_zip_entry_name(
|
|
entry.name_raw(),
|
|
);
|
|
name.to_lowercase()
|
|
}; // entry dropped here, releasing the mutable borrow on archive
|
|
|
|
// Check if it looks like a compressed file
|
|
if lower_name.ends_with(".zip") || lower_name.ends_with(".mrpack") {
|
|
// Extract this sub-archive
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::ExtractingOverrides,
|
|
Some(InstallProgress {
|
|
current: sub_archive_paths.len() as u64 + 1,
|
|
total: archive.len() as u64,
|
|
secondary: None,
|
|
}),
|
|
InstallPhaseDetails::Modpack {
|
|
project_id: None,
|
|
version_id: None,
|
|
title: Some(format!("Extracting {filename}")),
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let mut entry = archive
|
|
.by_index(i)
|
|
.map_err(|e| ErrorKind::OtherError(e.to_string()))?;
|
|
let sub_path = scratch.join(entry.mangled_name());
|
|
|
|
if let Some(parent) = sub_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let mut out = std::fs::File::create(&sub_path)?;
|
|
std::io::copy(&mut entry, &mut out)?;
|
|
|
|
sub_archive_paths.push(sub_path);
|
|
}
|
|
}
|
|
|
|
// Try to install each sub-archive recursively
|
|
for sub_path in sub_archive_paths {
|
|
let result = install_local_pack_file_recursive(
|
|
sub_path,
|
|
instance_id.clone(),
|
|
reporter.clone(),
|
|
current_depth + 1,
|
|
max_depth,
|
|
)
|
|
.await;
|
|
|
|
if result.is_ok() {
|
|
// Success! Clean up and return
|
|
let _ = tokio::fs::remove_dir_all(&scratch).await;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Clean up scratch directory
|
|
let _ = tokio::fs::remove_dir_all(&scratch).await;
|
|
}
|
|
|
|
// If all else fails, return error
|
|
Err(ErrorKind::InputError(
|
|
"Unrecognized modpack format: no known pack manifest was found in the archive"
|
|
.to_string(),
|
|
).into())
|
|
}
|
|
|
|
/// Dispatches a local non-mrpack modpack file to its format-specific
|
|
/// installer, based on the detected pack format.
|
|
#[async_recursion::async_recursion]
|
|
async fn install_local_pack_file(
|
|
detected: crate::api::pack::detect::DetectedLocalPack,
|
|
path: PathBuf,
|
|
instance_id: String,
|
|
reporter: InstallProgressReporter,
|
|
) -> crate::Result<InstallExecutionOutcome<()>> {
|
|
use crate::api::pack::detect::LocalPackFormat;
|
|
|
|
let source_filename = path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string());
|
|
match detected.format {
|
|
LocalPackFormat::Mrpack => {
|
|
let create_pack =
|
|
generate_pack_from_file(path, instance_id.clone()).await?;
|
|
return Ok(
|
|
match install_zipped_mrpack_files_with_reporter(
|
|
create_pack,
|
|
false,
|
|
DownloadReason::Modpack,
|
|
reporter,
|
|
)
|
|
.await?
|
|
{
|
|
MrpackInstallOutcome::Completed(_) => {
|
|
InstallExecutionOutcome::Completed(())
|
|
}
|
|
MrpackInstallOutcome::WaitingForUser(reason) => {
|
|
InstallExecutionOutcome::WaitingForUser(reason)
|
|
}
|
|
},
|
|
);
|
|
}
|
|
LocalPackFormat::CurseForge => {
|
|
let skipped_missing_content_paths = reporter
|
|
.current_state()
|
|
.await?
|
|
.skipped_missing_content_paths;
|
|
let result = crate::api::curseforge::install_modpack_from_local_archive_with_reporter(
|
|
instance_id,
|
|
path,
|
|
detected.base_folder,
|
|
source_filename,
|
|
false,
|
|
reporter,
|
|
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
|
|
)
|
|
.await?;
|
|
if let Some(reason) = curseforge_manual_download_pause(
|
|
&result,
|
|
&skipped_missing_content_paths,
|
|
) {
|
|
return Ok(InstallExecutionOutcome::WaitingForUser(reason));
|
|
}
|
|
}
|
|
LocalPackFormat::Mcbbs => {
|
|
crate::api::pack::install_mcbbs::install_mcbbs_pack_with_reporter(
|
|
instance_id,
|
|
path,
|
|
detected.base_folder,
|
|
source_filename,
|
|
reporter,
|
|
)
|
|
.await?;
|
|
}
|
|
LocalPackFormat::Hmcl => {
|
|
crate::api::pack::install_hmcl::install_hmcl_pack_with_reporter(
|
|
instance_id,
|
|
path,
|
|
detected.base_folder,
|
|
source_filename,
|
|
reporter,
|
|
)
|
|
.await?;
|
|
}
|
|
LocalPackFormat::MmcExport => {
|
|
crate::api::pack::install_mmc_zip::install_mmc_zip_with_reporter(
|
|
instance_id,
|
|
path,
|
|
detected.base_folder,
|
|
source_filename,
|
|
reporter,
|
|
)
|
|
.await?;
|
|
}
|
|
LocalPackFormat::LauncherBundled => {
|
|
let inner_entry = detected.inner_pack_entry.ok_or_else(|| {
|
|
ErrorKind::InputError(
|
|
"Launcher bundle is missing its inner modpack file"
|
|
.to_string(),
|
|
)
|
|
})?;
|
|
let state = State::get().await?;
|
|
let scratch =
|
|
crate::api::pack::archive_util::create_import_scratch_dir(
|
|
&state,
|
|
)
|
|
.await?;
|
|
let inner_name = inner_entry
|
|
.rsplit('/')
|
|
.next()
|
|
.unwrap_or("modpack.zip")
|
|
.to_string();
|
|
let inner_path = scratch.join(&inner_name);
|
|
crate::api::pack::archive_util::extract_archive_entry_to_file(
|
|
path,
|
|
inner_entry,
|
|
inner_path.clone(),
|
|
)
|
|
.await?;
|
|
|
|
// Use our recursive function to install the inner pack
|
|
let result = install_local_pack_file_recursive(
|
|
inner_path,
|
|
instance_id,
|
|
reporter,
|
|
1, // already one level deep
|
|
3,
|
|
)
|
|
.await;
|
|
|
|
// Clean up temporary directory
|
|
if let Err(error) = tokio::fs::remove_dir_all(&scratch).await {
|
|
tracing::warn!(
|
|
"Failed to clean up modpack import scratch directory {}: {error}",
|
|
scratch.display()
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
LocalPackFormat::PlainArchive => {
|
|
let version_id = detected.plain_version_id.ok_or_else(|| {
|
|
ErrorKind::InputError(
|
|
"Could not locate the instance version in the archive"
|
|
.to_string(),
|
|
)
|
|
})?;
|
|
crate::api::pack::install_plain_archive::install_plain_archive_with_reporter(
|
|
instance_id,
|
|
path,
|
|
detected.base_folder,
|
|
version_id,
|
|
source_filename,
|
|
reporter,
|
|
)
|
|
.await?;
|
|
}
|
|
LocalPackFormat::InstanceFolder => {
|
|
// Extract the base folder contents to a temporary directory
|
|
let state = State::get().await?;
|
|
let scratch =
|
|
crate::api::pack::archive_util::create_import_scratch_dir(
|
|
&state,
|
|
)
|
|
.await?;
|
|
|
|
// Extract the instance folder contents
|
|
crate::api::pack::archive_util::extract_archive_subdir(
|
|
path,
|
|
detected.base_folder,
|
|
scratch.clone(),
|
|
)
|
|
.await?;
|
|
|
|
// Now import it as a generic instance
|
|
let details = InstallPhaseDetails::Modpack {
|
|
project_id: None,
|
|
version_id: None,
|
|
title: source_filename.clone(),
|
|
};
|
|
|
|
crate::api::pack::import::generic::import_generic(
|
|
scratch,
|
|
&instance_id,
|
|
reporter,
|
|
details,
|
|
false,
|
|
&crate::api::pack::import::ImportOverrides::default(),
|
|
None, // Not compatible mode
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(InstallExecutionOutcome::Completed(()))
|
|
}
|
|
|
|
fn curseforge_manual_download_pause(
|
|
result: &crate::api::curseforge::CurseForgeModpackInstallResult,
|
|
skipped_missing_content_paths: &[String],
|
|
) -> Option<InstallPauseReason> {
|
|
let missing_downloads = result
|
|
.content
|
|
.manual_downloads
|
|
.iter()
|
|
.filter(|download| {
|
|
!skipped_missing_content_paths.contains(&download.file_name)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if missing_downloads.is_empty() {
|
|
return None;
|
|
}
|
|
Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: missing_downloads.len() as u64,
|
|
paths: missing_downloads
|
|
.iter()
|
|
.map(|download| download.file_name.clone())
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
fn curseforge_world_was_imported_manually(
|
|
job_state: &InstallJobState,
|
|
request: &crate::api::curseforge::CurseForgeWorldInstallRequest,
|
|
) -> bool {
|
|
let project_id = request.project_id.to_string();
|
|
let file_id = request.file_id.to_string();
|
|
job_state.download_items().iter().any(|item| {
|
|
item.status == super::model::DownloadItemStatus::Completed
|
|
&& item.project_id.as_deref() == Some(project_id.as_str())
|
|
&& item.version_id.as_deref() == Some(file_id.as_str())
|
|
})
|
|
}
|
|
|
|
async fn prepare_existing_rollback(
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
instance_id: &str,
|
|
) -> crate::Result<()> {
|
|
if job_state.rollback.is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
let instance = crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Unknown instance {instance_id}"
|
|
))
|
|
})?;
|
|
let install_stage = instance.instance.install_stage;
|
|
set_display(
|
|
job_state,
|
|
instance.instance.name.clone(),
|
|
instance.instance.icon_path.clone(),
|
|
);
|
|
job_state.rollback = Some(InstallRollbackState {
|
|
instance,
|
|
install_stage,
|
|
content: None,
|
|
});
|
|
job_state.cleanup = InstallCleanup::RestoreExistingInstance {
|
|
instance_id: instance_id.to_string(),
|
|
};
|
|
|
|
crate::state::instances::commands::set_instance_install_stage(
|
|
instance_id,
|
|
InstanceInstallStage::MinecraftInstalling,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_progress(
|
|
job_id: Uuid,
|
|
job_state: &mut InstallJobState,
|
|
state: &State,
|
|
phase: InstallPhaseId,
|
|
details: InstallPhaseDetails,
|
|
) -> crate::Result<()> {
|
|
job_state.set_progress(phase, None, details);
|
|
let record = store::update_state(job_id, job_state, state).await?;
|
|
emit_install_job(&record.snapshot()).await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn set_instance_id(job_state: &mut InstallJobState, instance_id: String) {
|
|
job_state.target = match &job_state.target {
|
|
InstallTarget::ExistingInstance { .. } => {
|
|
InstallTarget::ExistingInstance {
|
|
instance_id: instance_id.clone(),
|
|
}
|
|
}
|
|
InstallTarget::NewInstance { .. } => InstallTarget::NewInstance {
|
|
instance_id: Some(instance_id.clone()),
|
|
},
|
|
};
|
|
job_state.cleanup = match &job_state.cleanup {
|
|
InstallCleanup::RestoreExistingInstance { .. } => {
|
|
InstallCleanup::RestoreExistingInstance { instance_id }
|
|
}
|
|
InstallCleanup::DeleteNewInstance { .. } => {
|
|
InstallCleanup::DeleteNewInstance {
|
|
instance_id: Some(instance_id),
|
|
}
|
|
}
|
|
InstallCleanup::None => InstallCleanup::None,
|
|
};
|
|
}
|
|
|
|
fn clear_deleted_new_instance_id(job_state: &mut InstallJobState) {
|
|
if matches!(job_state.cleanup, InstallCleanup::DeleteNewInstance { .. }) {
|
|
job_state.target = InstallTarget::NewInstance { instance_id: None };
|
|
job_state.cleanup =
|
|
InstallCleanup::DeleteNewInstance { instance_id: None };
|
|
}
|
|
}
|
|
|
|
fn set_display(
|
|
job_state: &mut InstallJobState,
|
|
title: String,
|
|
icon: Option<String>,
|
|
) {
|
|
job_state.display = Some(InstallJobDisplay { title, icon });
|
|
}
|
|
|
|
fn install_error_view(
|
|
phase: InstallPhaseId,
|
|
error: &crate::Error,
|
|
context: Option<InstallErrorContext>,
|
|
) -> InstallErrorView {
|
|
let context = match error.raw.as_ref() {
|
|
ErrorKind::CacheReadError {
|
|
cache_type,
|
|
sqlite_code,
|
|
..
|
|
} => {
|
|
let mut context = context.unwrap_or_else(|| {
|
|
InstallErrorContext::new("read project metadata cache").build()
|
|
});
|
|
context.cache_types = vec![cache_type.clone()];
|
|
context.sqlite_code = sqlite_code.clone();
|
|
Some(context)
|
|
}
|
|
_ => context,
|
|
};
|
|
InstallErrorView::from_error(
|
|
install_error_code(phase, error),
|
|
phase,
|
|
error,
|
|
context,
|
|
)
|
|
}
|
|
|
|
fn install_error_code(
|
|
phase: InstallPhaseId,
|
|
error: &crate::Error,
|
|
) -> &'static str {
|
|
use InstallPhaseId::*;
|
|
|
|
match error.raw.as_ref() {
|
|
ErrorKind::CacheReadError { .. } => "cache_repair_required",
|
|
ErrorKind::InputError(msg)
|
|
if msg.starts_with("Unrecognized modpack format")
|
|
&& matches!(phase, ResolvingPack) =>
|
|
{
|
|
"unrecognized_format"
|
|
}
|
|
ErrorKind::InputError(_) => match phase {
|
|
PreparingInstance | CreatingBackup | Finalizing | Completed => {
|
|
"instance_error"
|
|
}
|
|
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
|
|
"pack_error"
|
|
}
|
|
DownloadingContent | StagingContent | ApplyingContent => {
|
|
"content_error"
|
|
}
|
|
ExtractingOverrides => "path_error",
|
|
PreparingJava => "java_error",
|
|
DownloadingMinecraft => "instance_error",
|
|
RollingBack => "rollback_error",
|
|
ResolvingMinecraft
|
|
| ResolvingLoader
|
|
| RunningLoaderProcessors
|
|
| UpdatingLoader
|
|
| Verifying => "launcher_error",
|
|
},
|
|
ErrorKind::LauncherError(_) => match phase {
|
|
RunningLoaderProcessors => "processor_error",
|
|
PreparingJava => "java_error",
|
|
ResolvingLoader => "loader_error",
|
|
_ => "launcher_error",
|
|
},
|
|
ErrorKind::JREError(_) => "java_error",
|
|
ErrorKind::NoValueFor(_) | ErrorKind::MetadataError(_) => match phase {
|
|
ResolvingLoader => "loader_error",
|
|
PreparingJava => "java_error",
|
|
_ => "metadata_error",
|
|
},
|
|
ErrorKind::FetchError(_)
|
|
| ErrorKind::NetworkError(_)
|
|
| ErrorKind::HttpError { .. }
|
|
| ErrorKind::ApiIsDownError(_) => "network_error",
|
|
ErrorKind::Any(_)
|
|
if matches!(
|
|
phase,
|
|
DownloadingPackFile
|
|
| DownloadingContent
|
|
| ResolvingMinecraft
|
|
| ResolvingLoader
|
|
| PreparingJava
|
|
| DownloadingMinecraft
|
|
) =>
|
|
{
|
|
"network_error"
|
|
}
|
|
ErrorKind::LabrinthError(_) => "api_error",
|
|
ErrorKind::HashError(_, _) => "hash_error",
|
|
ErrorKind::ZipError(_) => "archive_error",
|
|
ErrorKind::DeserializationError(_) | ErrorKind::StripPrefixError(_) => {
|
|
"path_error"
|
|
}
|
|
ErrorKind::FSError(_)
|
|
| ErrorKind::IOError(_)
|
|
| ErrorKind::StdIOError(_)
|
|
| ErrorKind::UTFError(_) => "filesystem_error",
|
|
ErrorKind::INIError(_) | ErrorKind::JSONError(_) => "parse_error",
|
|
ErrorKind::Sqlx(_) | ErrorKind::SqlxMigrate(_) => "database_error",
|
|
ErrorKind::JoinError(_)
|
|
| ErrorKind::RecvError(_)
|
|
| ErrorKind::AcquireError(_)
|
|
| ErrorKind::EventError(_) => "internal_error",
|
|
ErrorKind::OtherError(_) | ErrorKind::Any(_) => "internal_error",
|
|
_ => "unknown_error",
|
|
}
|
|
}
|
|
|
|
fn current_instance_id(job_state: &InstallJobState) -> Option<String> {
|
|
match &job_state.target {
|
|
InstallTarget::NewInstance { instance_id } => instance_id.clone(),
|
|
InstallTarget::ExistingInstance { instance_id } => {
|
|
Some(instance_id.clone())
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) const OPTIFABRIC_CURSEFORGE_PROJECT_ID: u32 = 322_385;
|
|
|
|
async fn resolve_required_adjuncts(
|
|
game_version: &str,
|
|
loader: ModLoader,
|
|
adjuncts: &mut Vec<LoaderComponent>,
|
|
_state: &State,
|
|
) -> crate::Result<()> {
|
|
for adjunct in adjuncts.iter() {
|
|
match adjunct.kind {
|
|
LoaderComponentKind::OptiFine
|
|
if !matches!(
|
|
loader,
|
|
ModLoader::Forge
|
|
| ModLoader::NeoForge
|
|
| ModLoader::Fabric
|
|
| ModLoader::LegacyFabric
|
|
) =>
|
|
{
|
|
return Err(ErrorKind::InputError(format!(
|
|
"OptiFine is not supported with {}",
|
|
loader.as_str()
|
|
))
|
|
.into());
|
|
}
|
|
LoaderComponentKind::LiteLoader if loader != ModLoader::Forge => {
|
|
return Err(ErrorKind::InputError(format!(
|
|
"LiteLoader is not supported with {}",
|
|
loader.as_str()
|
|
))
|
|
.into());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
for adjunct in adjuncts.iter_mut() {
|
|
adjunct.role = LoaderComponentRole::Adjunct;
|
|
adjunct.instance_id.clear();
|
|
match adjunct.kind {
|
|
LoaderComponentKind::OptiFine => {
|
|
let resolved =
|
|
crate::launcher::optifine::resolve_loader_version(
|
|
game_version,
|
|
adjunct.version.as_deref(),
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"No OptiFine version supports Minecraft {game_version}"
|
|
))
|
|
})?;
|
|
adjunct.version = Some(resolved.id);
|
|
}
|
|
LoaderComponentKind::LiteLoader => {
|
|
let resolved =
|
|
crate::launcher::get_loader_version_from_profile(
|
|
game_version,
|
|
ModLoader::LiteLoader,
|
|
adjunct.version.as_deref(),
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"No LiteLoader version supports Minecraft {game_version}"
|
|
))
|
|
})?;
|
|
adjunct.version = Some(resolved.id);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if adjuncts
|
|
.iter()
|
|
.any(|component| component.kind == LoaderComponentKind::OptiFine)
|
|
&& matches!(loader, ModLoader::Fabric | ModLoader::LegacyFabric)
|
|
&& !adjuncts
|
|
.iter()
|
|
.any(|component| component.kind == LoaderComponentKind::OptiFabric)
|
|
{
|
|
let version_id = resolve_optifabric_version(game_version).await?;
|
|
adjuncts.push(LoaderComponent {
|
|
instance_id: String::new(),
|
|
kind: LoaderComponentKind::OptiFabric,
|
|
version: Some(version_id),
|
|
role: LoaderComponentRole::Adjunct,
|
|
provider_metadata: Some(serde_json::json!({
|
|
"projectId": OPTIFABRIC_CURSEFORGE_PROJECT_ID,
|
|
"provider": "curseforge"
|
|
})),
|
|
});
|
|
}
|
|
let mut components =
|
|
vec![LoaderComponent::new_primary(String::new(), loader, None)];
|
|
components.extend(adjuncts.iter().cloned());
|
|
validate_loader_components(&components)
|
|
}
|
|
|
|
pub(crate) fn validate_loader_components(
|
|
components: &[LoaderComponent],
|
|
) -> crate::Result<()> {
|
|
let primary = components
|
|
.iter()
|
|
.find(|component| component.role == LoaderComponentRole::Primary)
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(
|
|
"Loader selection has no primary loader".to_string(),
|
|
)
|
|
})?;
|
|
let has = |kind| {
|
|
components.iter().any(|component| {
|
|
component.role == LoaderComponentRole::Adjunct
|
|
&& component.kind == kind
|
|
})
|
|
};
|
|
if has(LoaderComponentKind::OptiFine) {
|
|
match primary.kind {
|
|
LoaderComponentKind::Vanilla => {}
|
|
LoaderComponentKind::Forge | LoaderComponentKind::NeoForge => {}
|
|
LoaderComponentKind::Fabric | LoaderComponentKind::LegacyFabric
|
|
if has(LoaderComponentKind::OptiFabric) => {}
|
|
_ => {
|
|
return Err(ErrorKind::InputError(format!(
|
|
"OptiFine is not supported with {}",
|
|
primary.kind.as_str()
|
|
))
|
|
.into());
|
|
}
|
|
}
|
|
}
|
|
if has(LoaderComponentKind::OptiFabric)
|
|
&& !has(LoaderComponentKind::OptiFine)
|
|
{
|
|
return Err(ErrorKind::InputError(
|
|
"OptiFabric can only be installed with OptiFine".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
if has(LoaderComponentKind::OptiFabric)
|
|
&& !matches!(
|
|
primary.kind,
|
|
LoaderComponentKind::Fabric | LoaderComponentKind::LegacyFabric
|
|
)
|
|
{
|
|
return Err(ErrorKind::InputError(format!(
|
|
"OptiFabric is not supported with {}",
|
|
primary.kind.as_str()
|
|
))
|
|
.into());
|
|
}
|
|
if has(LoaderComponentKind::LiteLoader)
|
|
&& !matches!(
|
|
primary.kind,
|
|
LoaderComponentKind::Vanilla | LoaderComponentKind::Forge
|
|
)
|
|
{
|
|
return Err(ErrorKind::InputError(format!(
|
|
"LiteLoader is not supported with {}",
|
|
primary.kind.as_str()
|
|
))
|
|
.into());
|
|
}
|
|
if components.iter().any(|component| {
|
|
component.role == LoaderComponentRole::Adjunct
|
|
&& !matches!(
|
|
component.kind,
|
|
LoaderComponentKind::OptiFine
|
|
| LoaderComponentKind::LiteLoader
|
|
| LoaderComponentKind::OptiFabric
|
|
)
|
|
}) {
|
|
return Err(ErrorKind::InputError(
|
|
"Only OptiFine, LiteLoader, and OptiFabric can be adjunct loaders"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn resolve_optifabric_version(
|
|
game_version: &str,
|
|
) -> crate::Result<String> {
|
|
let files = crate::api::curseforge::get_files(
|
|
OPTIFABRIC_CURSEFORGE_PROJECT_ID,
|
|
crate::api::curseforge::CurseForgeFilesRequest {
|
|
game_version: None,
|
|
mod_loader_type: None,
|
|
game_version_type_id: None,
|
|
index: 0,
|
|
page_size: 50,
|
|
},
|
|
)
|
|
.await?
|
|
.files;
|
|
select_optifabric_file_id(&files, game_version)
|
|
.map(|file_id| file_id.to_string())
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"OptiFine requires OptiFabric, but no OptiFabric version supports Minecraft {game_version}"
|
|
))
|
|
.into()
|
|
})
|
|
}
|
|
|
|
fn select_optifabric_file_id(
|
|
files: &[crate::api::curseforge::CurseForgeFile],
|
|
game_version: &str,
|
|
) -> Option<u32> {
|
|
files
|
|
.iter()
|
|
.find(|file| {
|
|
file.is_available
|
|
&& file
|
|
.game_versions
|
|
.iter()
|
|
.any(|version| version == game_version)
|
|
})
|
|
.map(|file| file.id)
|
|
}
|
|
|
|
pub(crate) async fn install_optifabric_file(
|
|
instance_id: &str,
|
|
game_version: &str,
|
|
version: &str,
|
|
) -> crate::Result<String> {
|
|
let file_id = version.parse::<u32>().map_err(|_| {
|
|
ErrorKind::InputError(
|
|
"OptiFabric CurseForge file ID is invalid".to_string(),
|
|
)
|
|
})?;
|
|
let file = crate::api::curseforge::get_file(
|
|
OPTIFABRIC_CURSEFORGE_PROJECT_ID,
|
|
file_id,
|
|
)
|
|
.await?;
|
|
if file.mod_id != OPTIFABRIC_CURSEFORGE_PROJECT_ID
|
|
|| !file.is_available
|
|
|| !file
|
|
.game_versions
|
|
.iter()
|
|
.any(|version| version == game_version)
|
|
{
|
|
return Err(ErrorKind::InputError(format!(
|
|
"OptiFabric file {file_id} does not support Minecraft {game_version}"
|
|
))
|
|
.into());
|
|
}
|
|
|
|
let result = crate::api::curseforge::install_file(
|
|
crate::api::curseforge::CurseForgeInstallRequest {
|
|
instance_id: instance_id.to_string(),
|
|
project_id: OPTIFABRIC_CURSEFORGE_PROJECT_ID,
|
|
file_id,
|
|
project_type: "mod".to_string(),
|
|
ownership_kind: crate::state::instances::ContentOwnershipKind::UserAdded,
|
|
manual_operation_kind:
|
|
crate::state::instances::ManualDownloadOperationKind::ContentInstall,
|
|
game_version: Some(game_version.to_string()),
|
|
mod_loader_type: Some(4),
|
|
world_name: None,
|
|
install_dependencies: false,
|
|
excluded_dependency_project_ids: Vec::new(),
|
|
force_dependency_project_ids: Vec::new(),
|
|
dependency_plan_id: None,
|
|
},
|
|
)
|
|
.await?;
|
|
if !result.manual_downloads.is_empty() {
|
|
return Err(ErrorKind::InputError(
|
|
"OptiFabric requires a manual CurseForge download".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
if let Some(failure) = result.failed_downloads.first() {
|
|
return Err(ErrorKind::InputError(format!(
|
|
"Failed to install OptiFabric: {}",
|
|
failure.reason
|
|
))
|
|
.into());
|
|
}
|
|
if !result.installed.iter().any(|installed| {
|
|
!installed.dependency
|
|
&& installed.project_id == OPTIFABRIC_CURSEFORGE_PROJECT_ID
|
|
&& installed.file_id == file_id
|
|
}) {
|
|
return Err(ErrorKind::InputError(
|
|
"OptiFabric was not installed".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
Ok(file_id.to_string())
|
|
}
|
|
|
|
async fn install_adjunct_components(
|
|
state: &State,
|
|
instance_id: &str,
|
|
adjuncts: &[LoaderComponent],
|
|
game_version: &str,
|
|
loader: ModLoader,
|
|
cancellation: tokio_util::sync::CancellationToken,
|
|
) -> crate::Result<()> {
|
|
if adjuncts.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let metadata = crate::api::instance::get(instance_id)
|
|
.await?
|
|
.ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?;
|
|
let instance_path = state.directories.instance_game_dir(&metadata.instance);
|
|
let mut components = metadata.loader_components.clone();
|
|
|
|
for adjunct in adjuncts {
|
|
match adjunct.kind {
|
|
LoaderComponentKind::OptiFine => {
|
|
let version = crate::launcher::optifine::resolve_loader_version(
|
|
game_version,
|
|
adjunct.version.as_deref(),
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"No OptiFine version supports Minecraft {game_version}"
|
|
))
|
|
})?;
|
|
crate::api::pack::install_mcbbs::install_optifine_mod(
|
|
state,
|
|
instance_id,
|
|
cancellation.clone(),
|
|
game_version,
|
|
&version.id,
|
|
&instance_path,
|
|
)
|
|
.await?;
|
|
set_component_version(
|
|
&mut components,
|
|
LoaderComponentKind::OptiFine,
|
|
version.id,
|
|
);
|
|
}
|
|
LoaderComponentKind::OptiFabric => {
|
|
let version_id = match &adjunct.version {
|
|
Some(version) => version.clone(),
|
|
None => resolve_optifabric_version(game_version).await?,
|
|
};
|
|
let version_id = install_optifabric_file(
|
|
instance_id,
|
|
game_version,
|
|
&version_id,
|
|
)
|
|
.await?;
|
|
set_component_version(
|
|
&mut components,
|
|
LoaderComponentKind::OptiFabric,
|
|
version_id,
|
|
);
|
|
}
|
|
LoaderComponentKind::LiteLoader => {
|
|
let version = install_liteloader_adjunct(
|
|
state,
|
|
&metadata,
|
|
game_version,
|
|
loader,
|
|
adjunct.version.as_deref(),
|
|
)
|
|
.await?;
|
|
set_component_version(
|
|
&mut components,
|
|
LoaderComponentKind::LiteLoader,
|
|
version,
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
crate::state::instances::commands::replace_instance_loader_components(
|
|
instance_id,
|
|
&components,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn set_component_version(
|
|
components: &mut [LoaderComponent],
|
|
kind: LoaderComponentKind,
|
|
version: String,
|
|
) {
|
|
if let Some(component) = components
|
|
.iter_mut()
|
|
.find(|component| component.kind == kind)
|
|
{
|
|
component.version = Some(version);
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn install_liteloader_adjunct(
|
|
state: &State,
|
|
metadata: &crate::state::InstanceMetadata,
|
|
game_version: &str,
|
|
primary_loader: ModLoader,
|
|
requested_version: Option<&str>,
|
|
) -> crate::Result<String> {
|
|
let version = crate::launcher::get_loader_version_from_profile(
|
|
game_version,
|
|
ModLoader::LiteLoader,
|
|
requested_version,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"No LiteLoader version supports Minecraft {game_version}"
|
|
))
|
|
})?;
|
|
install_liteloader_adjunct_resolved(
|
|
state,
|
|
metadata,
|
|
game_version,
|
|
primary_loader,
|
|
&version,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn install_liteloader_adjunct_resolved(
|
|
state: &State,
|
|
metadata: &crate::state::InstanceMetadata,
|
|
game_version: &str,
|
|
primary_loader: ModLoader,
|
|
version: &daedalus::modded::LoaderVersion,
|
|
) -> crate::Result<String> {
|
|
let partial = crate::api::loader_metadata::resolve_loader_profile(
|
|
state,
|
|
game_version,
|
|
version,
|
|
)
|
|
.await?;
|
|
let primary_version = metadata
|
|
.applied_content_set
|
|
.loader_version
|
|
.as_deref()
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"{} adjunct installation requires a pinned primary version",
|
|
primary_loader.as_str()
|
|
))
|
|
})?;
|
|
let version_id = format!("{game_version}-{primary_version}");
|
|
let path = state
|
|
.directories
|
|
.version_dir(&version_id)
|
|
.join(format!("{version_id}.json"));
|
|
let bytes = crate::util::io::read(&path).await?;
|
|
let primary: daedalus::minecraft::VersionInfo =
|
|
serde_json::from_slice(&bytes)?;
|
|
let mut merged = daedalus::modded::merge_partial_version(partial, primary);
|
|
merged.id.clone_from(&version_id);
|
|
crate::launcher::download::download_libraries(
|
|
state,
|
|
None,
|
|
&merged.libraries,
|
|
&version_id,
|
|
None,
|
|
0.0,
|
|
std::env::consts::ARCH,
|
|
false,
|
|
false,
|
|
None,
|
|
)
|
|
.await?;
|
|
crate::util::io::write(&path, serde_json::to_vec(&merged)?).await?;
|
|
Ok(version.id.clone())
|
|
}
|
|
|
|
fn modpack_details(location: &CreatePackLocation) -> InstallPhaseDetails {
|
|
match location {
|
|
CreatePackLocation::FromVersionId {
|
|
project_id,
|
|
version_id,
|
|
title,
|
|
..
|
|
} => InstallPhaseDetails::Modpack {
|
|
project_id: Some(project_id.clone()),
|
|
version_id: Some(version_id.clone()),
|
|
title: Some(title.clone()),
|
|
},
|
|
CreatePackLocation::FromFile { .. } => InstallPhaseDetails::Modpack {
|
|
project_id: None,
|
|
version_id: None,
|
|
title: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn physical_test_item(
|
|
content_id: &str,
|
|
relative_path: &str,
|
|
status: crate::state::InstanceUpgradeItemStatus,
|
|
action: InstanceUpgradeAction,
|
|
current_enabled: bool,
|
|
) -> crate::state::InstanceUpgradeItem {
|
|
crate::state::InstanceUpgradeItem {
|
|
content_id: content_id.to_string(),
|
|
relative_path: relative_path.to_string(),
|
|
project_type: crate::state::ProjectType::Mod,
|
|
provider: None,
|
|
project_id: None,
|
|
current_release_id: None,
|
|
current_enabled,
|
|
auto_dependency: false,
|
|
status,
|
|
resolution: crate::state::InstanceUpgradeResolution {
|
|
content_id: content_id.to_string(),
|
|
action,
|
|
allow_prerelease: false,
|
|
confirmed_prerelease_dependencies: Vec::new(),
|
|
},
|
|
candidate_release_ids: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn physical_test_execution(
|
|
items: Vec<crate::state::InstanceUpgradeItem>,
|
|
selections: Vec<crate::state::InstanceUpgradeSelection>,
|
|
) -> InstanceUpgradeExecution {
|
|
let environment = crate::state::InstanceUpgradeEnvironment {
|
|
game_version: "1.21.9".to_string(),
|
|
mod_loader: ModLoader::Fabric,
|
|
mod_loader_version: Some("0.18.5".to_string()),
|
|
shader_runtime: crate::state::ShaderRuntime::Iris,
|
|
};
|
|
InstanceUpgradeExecution {
|
|
source_revision: 1,
|
|
source_files: Vec::new(),
|
|
source_environment: environment.clone(),
|
|
target_environment: environment,
|
|
items,
|
|
solution: crate::state::InstanceUpgradeSolution {
|
|
kind: crate::state::InstanceUpgradeSolutionKind::Custom,
|
|
selections,
|
|
dependency_changes: Vec::new(),
|
|
warnings: Vec::new(),
|
|
},
|
|
warnings: Vec::new(),
|
|
source_watch: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn final_physical_decision_uses_solution_then_item_resolution() {
|
|
let solver_item = physical_test_item(
|
|
"solver",
|
|
"mods/solver.jar",
|
|
crate::state::InstanceUpgradeItemStatus::UpgradeAvailable,
|
|
InstanceUpgradeAction::Disable,
|
|
true,
|
|
);
|
|
let local_disable = physical_test_item(
|
|
"local-disable",
|
|
"mods/local-disable.jar",
|
|
crate::state::InstanceUpgradeItemStatus::Unidentified,
|
|
InstanceUpgradeAction::Disable,
|
|
true,
|
|
);
|
|
let local_keep_disabled = physical_test_item(
|
|
"local-keep",
|
|
"mods/local-keep.jar.disabled",
|
|
crate::state::InstanceUpgradeItemStatus::Unidentified,
|
|
InstanceUpgradeAction::Keep,
|
|
false,
|
|
);
|
|
let execution = physical_test_execution(
|
|
vec![
|
|
solver_item.clone(),
|
|
local_disable.clone(),
|
|
local_keep_disabled.clone(),
|
|
],
|
|
vec![crate::state::InstanceUpgradeSelection {
|
|
content_id: "solver".to_string(),
|
|
provider: Some(ContentProvider::Modrinth),
|
|
project_id: Some("project".to_string()),
|
|
current_release_id: Some("old".to_string()),
|
|
target_release_id: None,
|
|
action: InstanceUpgradeAction::Keep,
|
|
enabled: true,
|
|
}],
|
|
);
|
|
|
|
assert_eq!(
|
|
execution.final_physical_decision(&solver_item),
|
|
(InstanceUpgradeAction::Keep, true)
|
|
);
|
|
assert_eq!(
|
|
execution.final_physical_decision(&local_disable),
|
|
(InstanceUpgradeAction::Disable, false)
|
|
);
|
|
assert_eq!(
|
|
execution.final_physical_decision(&local_keep_disabled),
|
|
(InstanceUpgradeAction::Keep, false)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_upgrade_name_uses_exact_resolved_loader_version() {
|
|
assert_eq!(
|
|
default_upgrade_instance_name(
|
|
&crate::state::InstanceUpgradeEnvironment {
|
|
game_version: "1.21.9".to_string(),
|
|
mod_loader: ModLoader::Fabric,
|
|
mod_loader_version: Some("0.18.5".to_string()),
|
|
shader_runtime: crate::state::ShaderRuntime::Iris,
|
|
},
|
|
),
|
|
"1.21.9-Fabric 0.18.5"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compatibility_warning_details_recover_unique_exact_content_identity() {
|
|
let environment = crate::state::InstanceUpgradeEnvironment {
|
|
game_version: "1.21.9".to_string(),
|
|
mod_loader: ModLoader::Fabric,
|
|
mod_loader_version: Some("0.18.5".to_string()),
|
|
shader_runtime: crate::state::ShaderRuntime::Iris,
|
|
};
|
|
let execution = InstanceUpgradeExecution {
|
|
source_revision: 1,
|
|
source_files: Vec::new(),
|
|
source_environment: environment.clone(),
|
|
target_environment: environment,
|
|
items: vec![crate::state::InstanceUpgradeItem {
|
|
content_id: "content".to_string(),
|
|
relative_path: "resourcepacks/foo.zip".to_string(),
|
|
project_type: crate::state::ProjectType::ResourcePack,
|
|
provider: Some(crate::state::ContentProvider::Modrinth),
|
|
project_id: Some("project".to_string()),
|
|
current_release_id: Some("release".to_string()),
|
|
current_enabled: true,
|
|
auto_dependency: false,
|
|
status:
|
|
crate::state::InstanceUpgradeItemStatus::NoCompatibleRelease,
|
|
resolution: crate::state::InstanceUpgradeResolution {
|
|
content_id: "content".to_string(),
|
|
action: crate::state::InstanceUpgradeAction::Keep,
|
|
allow_prerelease: false,
|
|
confirmed_prerelease_dependencies: Vec::new(),
|
|
},
|
|
candidate_release_ids: Vec::new(),
|
|
}],
|
|
solution: crate::state::InstanceUpgradeSolution {
|
|
kind: crate::state::InstanceUpgradeSolutionKind::Custom,
|
|
selections: Vec::new(),
|
|
dependency_changes: Vec::new(),
|
|
warnings: Vec::new(),
|
|
},
|
|
warnings: vec![crate::state::InstanceUpgradeIssue {
|
|
code: crate::state::InstanceUpgradeIssueCode::KeepIncompatible,
|
|
message: "preserved".to_string(),
|
|
content_id: None,
|
|
provider: Some(crate::state::ContentProvider::Modrinth),
|
|
project_id: Some("project".to_string()),
|
|
conflicting_project_id: None,
|
|
dependency_requirements: Vec::new(),
|
|
}],
|
|
source_watch: None,
|
|
};
|
|
|
|
let details = upgrade_compatibility_warning_details(&execution);
|
|
assert_eq!(details.len(), 1);
|
|
assert_eq!(details[0].content_id.as_deref(), Some("content"));
|
|
assert_eq!(
|
|
details[0].relative_path.as_deref(),
|
|
Some("resourcepacks/foo.zip")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_path_only_preserve_warnings_create_physical_notice_details() {
|
|
let environment = crate::state::InstanceUpgradeEnvironment {
|
|
game_version: "1.21.9".to_string(),
|
|
mod_loader: ModLoader::Fabric,
|
|
mod_loader_version: Some("0.18.5".to_string()),
|
|
shader_runtime: crate::state::ShaderRuntime::Iris,
|
|
};
|
|
let item = |content_id: &str, relative_path: &str| {
|
|
crate::state::InstanceUpgradeItem {
|
|
content_id: content_id.to_string(),
|
|
relative_path: relative_path.to_string(),
|
|
project_type: crate::state::ProjectType::ResourcePack,
|
|
provider: None,
|
|
project_id: None,
|
|
current_release_id: None,
|
|
current_enabled: true,
|
|
auto_dependency: false,
|
|
status:
|
|
crate::state::InstanceUpgradeItemStatus::NoCompatibleRelease,
|
|
resolution: crate::state::InstanceUpgradeResolution {
|
|
content_id: content_id.to_string(),
|
|
action: crate::state::InstanceUpgradeAction::Keep,
|
|
allow_prerelease: false,
|
|
confirmed_prerelease_dependencies: Vec::new(),
|
|
},
|
|
candidate_release_ids: Vec::new(),
|
|
}
|
|
};
|
|
let execution = InstanceUpgradeExecution {
|
|
source_revision: 1,
|
|
source_files: Vec::new(),
|
|
source_environment: environment.clone(),
|
|
target_environment: environment,
|
|
items: vec![
|
|
item("resource-pack", "resourcepacks/foo.zip"),
|
|
item("shader-pack", "shaderpacks/bar.zip"),
|
|
],
|
|
solution: crate::state::InstanceUpgradeSolution {
|
|
kind: crate::state::InstanceUpgradeSolutionKind::Custom,
|
|
selections: Vec::new(),
|
|
dependency_changes: Vec::new(),
|
|
warnings: Vec::new(),
|
|
},
|
|
warnings: vec![crate::state::InstanceUpgradeIssue {
|
|
code: crate::state::InstanceUpgradeIssueCode::KeepIncompatible,
|
|
message: "local content preserved".to_string(),
|
|
content_id: None,
|
|
provider: None,
|
|
project_id: None,
|
|
conflicting_project_id: None,
|
|
dependency_requirements: Vec::new(),
|
|
}],
|
|
source_watch: None,
|
|
};
|
|
|
|
let details = upgrade_compatibility_warning_details(&execution);
|
|
assert_eq!(details.len(), 2);
|
|
assert!(details.iter().any(|detail| {
|
|
detail.relative_path.as_deref() == Some("resourcepacks/foo.zip")
|
|
&& detail.provider.is_none()
|
|
&& detail.project_id.is_none()
|
|
}));
|
|
assert_eq!(
|
|
details
|
|
.iter()
|
|
.filter(|detail| detail.relative_path.is_some())
|
|
.count(),
|
|
2
|
|
);
|
|
}
|
|
|
|
fn upgrade_job_state() -> InstallJobState {
|
|
let environment = crate::state::InstanceUpgradeEnvironment {
|
|
game_version: "1.21.1".to_string(),
|
|
mod_loader: ModLoader::Fabric,
|
|
mod_loader_version: Some("0.16.0".to_string()),
|
|
shader_runtime: crate::state::ShaderRuntime::Iris,
|
|
};
|
|
InstallJobState::new(InstallRequest::UpgradeUnmanagedInstance {
|
|
instance_id: "instance".to_string(),
|
|
plan_id: "plan".to_string(),
|
|
execution: InstanceUpgradeExecution {
|
|
source_revision: 1,
|
|
source_files: Vec::new(),
|
|
source_environment: environment.clone(),
|
|
target_environment: environment,
|
|
items: Vec::new(),
|
|
solution: crate::state::InstanceUpgradeSolution {
|
|
kind: crate::state::InstanceUpgradeSolutionKind::Custom,
|
|
selections: Vec::new(),
|
|
dependency_changes: Vec::new(),
|
|
warnings: Vec::new(),
|
|
},
|
|
warnings: Vec::new(),
|
|
source_watch: None,
|
|
},
|
|
create_full_backup: false,
|
|
shared_upgrade_mode: SharedUpgradeMode::Direct,
|
|
display_names: InstanceUpgradeDisplayNames::default(),
|
|
})
|
|
}
|
|
|
|
fn components(
|
|
primary: ModLoader,
|
|
adjuncts: &[LoaderComponentKind],
|
|
) -> Vec<LoaderComponent> {
|
|
std::iter::once(LoaderComponent::new_primary("", primary, None))
|
|
.chain(adjuncts.iter().map(|kind| LoaderComponent {
|
|
instance_id: String::new(),
|
|
kind: *kind,
|
|
version: None,
|
|
role: LoaderComponentRole::Adjunct,
|
|
provider_metadata: None,
|
|
}))
|
|
.collect()
|
|
}
|
|
|
|
fn curseforge_file(
|
|
id: u32,
|
|
is_available: bool,
|
|
game_versions: &[&str],
|
|
) -> crate::api::curseforge::CurseForgeFile {
|
|
crate::api::curseforge::CurseForgeFile {
|
|
id,
|
|
game_id: 432,
|
|
mod_id: OPTIFABRIC_CURSEFORGE_PROJECT_ID,
|
|
is_available,
|
|
display_name: String::new(),
|
|
file_name: String::new(),
|
|
release_type: 1,
|
|
file_status: 4,
|
|
hashes: Vec::new(),
|
|
file_date: String::new(),
|
|
file_length: 0,
|
|
download_count: 0,
|
|
file_size_on_disk: None,
|
|
download_url: None,
|
|
game_versions: game_versions
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect(),
|
|
sortable_game_versions: Vec::new(),
|
|
dependencies: Vec::new(),
|
|
expose_as_alternative: None,
|
|
parent_project_file_id: None,
|
|
alternate_file_id: None,
|
|
is_server_pack: None,
|
|
server_pack_file_id: None,
|
|
is_early_access_content: None,
|
|
early_access_end_date: None,
|
|
file_fingerprint: 0,
|
|
modules: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn loader_component_preflight_accepts_verified_combinations() {
|
|
for components in [
|
|
components(ModLoader::Vanilla, &[LoaderComponentKind::OptiFine]),
|
|
components(ModLoader::Vanilla, &[LoaderComponentKind::LiteLoader]),
|
|
components(ModLoader::Forge, &[LoaderComponentKind::OptiFine]),
|
|
components(ModLoader::NeoForge, &[LoaderComponentKind::OptiFine]),
|
|
components(ModLoader::Forge, &[LoaderComponentKind::LiteLoader]),
|
|
components(
|
|
ModLoader::Fabric,
|
|
&[
|
|
LoaderComponentKind::OptiFine,
|
|
LoaderComponentKind::OptiFabric,
|
|
],
|
|
),
|
|
] {
|
|
validate_loader_components(&components).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn loader_component_preflight_rejects_unverified_combinations() {
|
|
for components in [
|
|
components(ModLoader::Quilt, &[LoaderComponentKind::OptiFine]),
|
|
components(ModLoader::Cleanroom, &[LoaderComponentKind::OptiFine]),
|
|
components(
|
|
ModLoader::LegacyFabric,
|
|
&[LoaderComponentKind::LiteLoader],
|
|
),
|
|
components(ModLoader::Fabric, &[LoaderComponentKind::LiteLoader]),
|
|
components(ModLoader::Fabric, &[LoaderComponentKind::OptiFine]),
|
|
components(
|
|
ModLoader::Forge,
|
|
&[
|
|
LoaderComponentKind::OptiFine,
|
|
LoaderComponentKind::OptiFabric,
|
|
],
|
|
),
|
|
] {
|
|
assert!(validate_loader_components(&components).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn optifabric_selection_requires_an_available_exact_game_version() {
|
|
let files = vec![
|
|
curseforge_file(1, true, &["1.19.2"]),
|
|
curseforge_file(2, false, &["1.20.1"]),
|
|
curseforge_file(3, true, &["1.20.1"]),
|
|
];
|
|
|
|
assert_eq!(select_optifabric_file_id(&files, "1.20.1"), Some(3));
|
|
assert_eq!(select_optifabric_file_id(&files, "1.20.2"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn stalled_downloads_are_reported_as_network_errors() {
|
|
let error: crate::Error = crate::ErrorKind::NetworkError(
|
|
"no data received for 60 seconds".to_string(),
|
|
)
|
|
.into();
|
|
|
|
assert_eq!(
|
|
install_error_code(InstallPhaseId::DownloadingMinecraft, &error),
|
|
"network_error"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_read_errors_have_repair_context_but_generic_sqlx_does_not() {
|
|
let cache_error: crate::Error = crate::ErrorKind::CacheReadError {
|
|
cache_type: "curseforge_project".to_string(),
|
|
message: "malformed cache row".to_string(),
|
|
sqlite_code: Some("11".to_string()),
|
|
}
|
|
.into();
|
|
let view = install_error_view(
|
|
InstallPhaseId::ResolvingPack,
|
|
&cache_error,
|
|
None,
|
|
);
|
|
assert_eq!(view.code, "cache_repair_required");
|
|
let context = view.context.unwrap();
|
|
assert_eq!(context.cache_types, vec!["curseforge_project"]);
|
|
assert_eq!(context.sqlite_code.as_deref(), Some("11"));
|
|
|
|
let database_error: crate::Error =
|
|
crate::ErrorKind::Sqlx(sqlx::Error::RowNotFound).into();
|
|
assert_eq!(
|
|
install_error_code(InstallPhaseId::ResolvingPack, &database_error),
|
|
"database_error"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_repair_validation_rejects_old_or_unknown_context() {
|
|
let old_context = InstallErrorContext::new("read cache").build();
|
|
let old_error = InstallErrorView::from_message(
|
|
"cache_repair_required",
|
|
InstallPhaseId::ResolvingPack,
|
|
"cache failed",
|
|
);
|
|
assert!(
|
|
validated_cache_repair_types_for(
|
|
InstallJobStatus::Failed,
|
|
Some(&InstallErrorView {
|
|
context: Some(old_context),
|
|
..old_error.clone()
|
|
}),
|
|
)
|
|
.is_err()
|
|
);
|
|
|
|
let mut unknown_context =
|
|
InstallErrorContext::new("read cache").build();
|
|
unknown_context.cache_types = vec!["install_jobs".to_string()];
|
|
assert!(
|
|
validated_cache_repair_types_for(
|
|
InstallJobStatus::Failed,
|
|
Some(&InstallErrorView {
|
|
context: Some(unknown_context),
|
|
..old_error
|
|
}),
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_repair_validation_accepts_only_whitelisted_terminal_jobs() {
|
|
let mut context = InstallErrorContext::new("read cache").build();
|
|
context.cache_types = vec![
|
|
"curseforge_project".to_string(),
|
|
"curseforge_project".to_string(),
|
|
];
|
|
let error = InstallErrorView {
|
|
code: "cache_repair_required".to_string(),
|
|
phase: Some(InstallPhaseId::ResolvingPack),
|
|
message: "cache failed".to_string(),
|
|
api: None,
|
|
context: Some(context),
|
|
};
|
|
assert_eq!(
|
|
validated_cache_repair_types_for(
|
|
InstallJobStatus::Interrupted,
|
|
Some(&error),
|
|
)
|
|
.unwrap(),
|
|
vec![crate::state::CacheValueType::CurseForgeProject]
|
|
);
|
|
assert!(
|
|
validated_cache_repair_types_for(
|
|
InstallJobStatus::Running,
|
|
Some(&error),
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_required_content_pauses_without_starting_rollback() {
|
|
let mut job_state =
|
|
InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
job_state.progress.phase = InstallPhaseId::DownloadingContent;
|
|
job_state.cleanup = InstallCleanup::DeleteNewInstance {
|
|
instance_id: Some("same-instance".to_string()),
|
|
};
|
|
let cleanup = job_state.cleanup.clone();
|
|
let reason = InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 2,
|
|
paths: vec!["mods/a.jar".to_string(), "mods/b.jar".to_string()],
|
|
};
|
|
|
|
begin_waiting_for_user(&mut job_state, reason.clone());
|
|
|
|
assert_eq!(
|
|
job_state.progress.phase,
|
|
InstallPhaseId::DownloadingContent
|
|
);
|
|
assert_eq!(job_state.pause_reason, Some(reason));
|
|
assert_eq!(job_state.cleanup, cleanup);
|
|
assert!(job_state.error.is_none());
|
|
assert!(job_state.rollback.is_none());
|
|
assert!(job_state.events.iter().any(|event| matches!(
|
|
&event.kind,
|
|
InstallJobEventKind::WaitingForUser { .. }
|
|
)));
|
|
assert!(!job_state.events.iter().any(|event| matches!(
|
|
&event.kind,
|
|
InstallJobEventKind::RollbackStarted { .. }
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn curseforge_manual_downloads_create_a_recoverable_pause() {
|
|
let manual_download =
|
|
crate::api::curseforge::CurseForgeManualDownload {
|
|
project_id: 123,
|
|
file_id: 456,
|
|
file_name: "mods/manual.jar".to_string(),
|
|
ownership_kind:
|
|
crate::state::instances::ContentOwnershipKind::PackManaged,
|
|
operation_kind: crate::state::instances::ManualDownloadOperationKind::PackInstall,
|
|
website_url: Some(
|
|
"https://www.curseforge.com/minecraft/mc-mods/example/download/456"
|
|
.to_string(),
|
|
),
|
|
project_type: "mod".to_string(),
|
|
project_slug: "example".to_string(),
|
|
target_folder: "mods".to_string(),
|
|
hashes: Vec::new(),
|
|
file_length: 12,
|
|
file_fingerprint: 34,
|
|
};
|
|
let result = crate::api::curseforge::CurseForgeModpackInstallResult {
|
|
content: crate::api::curseforge::CurseForgeInstallResult {
|
|
manual_downloads: vec![manual_download],
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
curseforge_manual_download_pause(&result, &[]),
|
|
Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec!["mods/manual.jar".to_string()],
|
|
})
|
|
);
|
|
assert_eq!(
|
|
curseforge_manual_download_pause(
|
|
&result,
|
|
&["mods/manual.jar".to_string()],
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recovered_manual_world_download_does_not_run_again() {
|
|
let request = crate::api::curseforge::CurseForgeWorldInstallRequest {
|
|
instance_id: "instance".to_string(),
|
|
project_id: 123,
|
|
file_id: 456,
|
|
};
|
|
let mut job_state =
|
|
InstallJobState::new(InstallRequest::InstallCurseForgeWorld {
|
|
request: request.clone(),
|
|
display_title: "World".to_string(),
|
|
display_icon: None,
|
|
});
|
|
job_state.record_event(InstallJobEventKind::ContentFileSkipped {
|
|
path: "saves/world.zip".to_string(),
|
|
reason: "manual download required".to_string(),
|
|
project_id: Some(request.project_id.to_string()),
|
|
version_id: Some(request.file_id.to_string()),
|
|
manual_url: Some("https://www.curseforge.com/download".to_string()),
|
|
});
|
|
job_state.record_event(InstallJobEventKind::ContentFileRecovered {
|
|
path: "saves/world.zip".to_string(),
|
|
bytes: 42,
|
|
});
|
|
|
|
assert!(curseforge_world_was_imported_manually(&job_state, &request));
|
|
}
|
|
|
|
#[test]
|
|
fn resume_preserves_instance_cleanup_and_existing_pack_checkpoint() {
|
|
let mut job_state =
|
|
InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
job_state.target = InstallTarget::NewInstance {
|
|
instance_id: Some("same-instance".to_string()),
|
|
};
|
|
job_state.cleanup = InstallCleanup::DeleteNewInstance {
|
|
instance_id: Some("same-instance".to_string()),
|
|
};
|
|
job_state.continuation =
|
|
Some(InstallContinuationState::InstallingPackToExistingInstance {
|
|
disabled_project_ids: vec!["project-a".to_string()],
|
|
});
|
|
job_state.pause_reason =
|
|
Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec!["mods/a.jar".to_string()],
|
|
});
|
|
let target = job_state.target.clone();
|
|
let cleanup = job_state.cleanup.clone();
|
|
let continuation = job_state.continuation.clone();
|
|
|
|
prepare_resumed_job(&mut job_state);
|
|
|
|
assert_eq!(job_state.target, target);
|
|
assert_eq!(job_state.cleanup, cleanup);
|
|
assert_eq!(job_state.continuation, continuation);
|
|
assert!(job_state.pause_reason.is_none());
|
|
assert!(job_state.events.iter().any(|event| matches!(
|
|
&event.kind,
|
|
InstallJobEventKind::JobQueued { .. }
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn resumed_job_can_pause_again_without_rollback() {
|
|
let mut job_state =
|
|
InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
job_state.pause_reason =
|
|
Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec!["mods/first.jar".to_string()],
|
|
});
|
|
prepare_resumed_job(&mut job_state);
|
|
begin_waiting_for_user(
|
|
&mut job_state,
|
|
InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec!["mods/still-missing.jar".to_string()],
|
|
},
|
|
);
|
|
|
|
assert!(matches!(
|
|
job_state.pause_reason,
|
|
Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
..
|
|
})
|
|
));
|
|
assert!(!job_state.events.iter().any(|event| matches!(
|
|
&event.kind,
|
|
InstallJobEventKind::RollbackStarted { .. }
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn canceling_waiting_jobs_keeps_the_original_cleanup_plan() {
|
|
for cleanup in [
|
|
InstallCleanup::DeleteNewInstance {
|
|
instance_id: Some("new-instance".to_string()),
|
|
},
|
|
InstallCleanup::RestoreExistingInstance {
|
|
instance_id: "existing-instance".to_string(),
|
|
},
|
|
] {
|
|
let mut job_state =
|
|
InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
job_state.cleanup = cleanup.clone();
|
|
job_state.pause_reason =
|
|
Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec!["mods/missing.jar".to_string()],
|
|
});
|
|
|
|
begin_canceling_job(&mut job_state);
|
|
|
|
assert_eq!(job_state.cleanup, cleanup);
|
|
assert!(job_state.pause_reason.is_none());
|
|
assert_eq!(job_state.progress.phase, InstallPhaseId::RollingBack);
|
|
assert!(job_state.events.iter().any(|event| matches!(
|
|
&event.kind,
|
|
InstallJobEventKind::RollbackStarted {
|
|
cleanup: event_cleanup,
|
|
} if event_cleanup == &cleanup
|
|
)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_and_override_errors_remain_fatal() {
|
|
for (phase, message) in [
|
|
(
|
|
InstallPhaseId::ReadingPackManifest,
|
|
"No pack manifest found in mrpack",
|
|
),
|
|
(InstallPhaseId::ExtractingOverrides, "Invalid override path"),
|
|
] {
|
|
let mut job_state =
|
|
InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
job_state.progress.phase = phase;
|
|
let error: crate::Error =
|
|
crate::ErrorKind::InputError(message.to_string()).into();
|
|
|
|
begin_failed_job_rollback(&mut job_state, &error);
|
|
|
|
assert!(job_state.pause_reason.is_none());
|
|
assert_eq!(job_state.progress.phase, InstallPhaseId::RollingBack);
|
|
assert!(job_state.events.iter().any(|event| matches!(
|
|
&event.kind,
|
|
InstallJobEventKind::Failed {
|
|
phase: failed_phase,
|
|
..
|
|
} if *failed_phase == phase
|
|
)));
|
|
assert!(job_state.events.iter().any(|event| matches!(
|
|
&event.kind,
|
|
InstallJobEventKind::RollbackStarted { .. }
|
|
)));
|
|
}
|
|
}
|
|
|
|
/// The launcher state is a process-wide singleton; initialize it once and
|
|
/// reuse it so `State::get()` resolves inside these APIs. The state root
|
|
/// is intentionally leaked (`.keep()`) because the shared state outlives
|
|
/// this function.
|
|
async fn global_state() -> std::sync::Arc<State> {
|
|
if !State::initialized() {
|
|
let root = tempfile::tempdir().unwrap().keep();
|
|
let _ =
|
|
State::init_for_test(root.to_string_lossy().to_string()).await;
|
|
}
|
|
State::get().await.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_link_instances_cannot_be_duplicated() {
|
|
let state = global_state().await;
|
|
let minecraft = tempfile::tempdir().unwrap();
|
|
let version_dir = minecraft.path().join("versions/duplicate-demo");
|
|
std::fs::create_dir_all(&version_dir).unwrap();
|
|
std::fs::write(
|
|
version_dir.join("duplicate-demo.json"),
|
|
serde_json::to_vec_pretty(&serde_json::json!({
|
|
"id": "duplicate-demo",
|
|
"inheritsFrom": "1.20.1",
|
|
"mainClass": "net.minecraft.client.main.Main"
|
|
}))
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let instance = crate::state::create_direct_link_instance(
|
|
crate::state::CreateDirectLinkInstance {
|
|
name: None,
|
|
launcher_type:
|
|
crate::api::pack::import::ImportLauncherType::Generic,
|
|
base_path: minecraft.path().to_path_buf(),
|
|
instance_folder: "versions/duplicate-demo".to_string(),
|
|
instance_path: None,
|
|
game_dir_mode: None,
|
|
},
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let error = duplicate_instance(instance.id)
|
|
.await
|
|
.expect_err("directly associated instances must be rejected");
|
|
|
|
assert!(
|
|
error.to_string().contains("directly associated"),
|
|
"expected a friendly rejection, got: {error}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn upgrade_failure_preserves_applying_phase_after_successful_rollback() {
|
|
let mut reporter_state = upgrade_job_state();
|
|
reporter_state.set_progress(
|
|
InstallPhaseId::StagingContent,
|
|
None,
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
let mut execution_state = reporter_state.clone();
|
|
execution_state.set_progress(
|
|
InstallPhaseId::ApplyingContent,
|
|
None,
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
let failed_phase =
|
|
latest_failure_phase(&execution_state, &reporter_state);
|
|
let mut terminal_state = reporter_state;
|
|
terminal_state.progress.phase = failed_phase;
|
|
let error: crate::Error = crate::ErrorKind::InputError(
|
|
"Injected unmanaged instance upgrade failure after 1 mutation(s)"
|
|
.to_string(),
|
|
)
|
|
.into();
|
|
|
|
begin_failed_job_rollback(&mut terminal_state, &error);
|
|
recovery::finalize_rollback_state(&mut terminal_state, true);
|
|
let status = InstallJobStatus::Failed;
|
|
|
|
assert_eq!(status, InstallJobStatus::Failed);
|
|
assert_eq!(terminal_state.progress.phase, InstallPhaseId::Finalizing);
|
|
assert_eq!(
|
|
terminal_state.error.as_ref().and_then(|error| error.phase),
|
|
Some(InstallPhaseId::ApplyingContent)
|
|
);
|
|
assert!(terminal_state.rollback_error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn interrupted_upgrade_uses_terminal_phase_after_successful_recovery() {
|
|
let mut job_state = upgrade_job_state();
|
|
job_state.progress.phase = InstallPhaseId::RollingBack;
|
|
job_state.error = Some(InstallErrorView::from_message(
|
|
"app_closed",
|
|
InstallPhaseId::ApplyingContent,
|
|
"App closed while install was running",
|
|
));
|
|
|
|
recovery::finalize_rollback_state(&mut job_state, true);
|
|
let status = InstallJobStatus::Interrupted;
|
|
|
|
assert_eq!(status, InstallJobStatus::Interrupted);
|
|
assert_eq!(job_state.progress.phase, InstallPhaseId::Finalizing);
|
|
assert!(job_state.rollback_error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rollback_failure_keeps_recovery_phase_and_error() {
|
|
let mut job_state = upgrade_job_state();
|
|
job_state.progress.phase = InstallPhaseId::RollingBack;
|
|
job_state.rollback_error = Some(InstallErrorView::from_message(
|
|
"rollback_error",
|
|
InstallPhaseId::RollingBack,
|
|
"rollback failed",
|
|
));
|
|
job_state.record_event(InstallJobEventKind::RollbackFailed {
|
|
message: "rollback failed".to_string(),
|
|
});
|
|
|
|
recovery::finalize_rollback_state(&mut job_state, false);
|
|
|
|
assert_eq!(job_state.progress.phase, InstallPhaseId::RollingBack);
|
|
assert_eq!(
|
|
job_state
|
|
.rollback_error
|
|
.as_ref()
|
|
.and_then(|error| error.phase),
|
|
Some(InstallPhaseId::RollingBack)
|
|
);
|
|
assert!(job_state.events.iter().any(|event| matches!(
|
|
event.kind,
|
|
InstallJobEventKind::RollbackFailed { .. }
|
|
)));
|
|
assert!(!job_state.events.iter().any(|event| matches!(
|
|
event.kind,
|
|
InstallJobEventKind::RollbackCompleted
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_direct_backup_honors_enabled_option() {
|
|
assert!(should_create_upgrade_backup(
|
|
true,
|
|
SharedUpgradeMode::Direct
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_direct_backup_can_be_disabled() {
|
|
assert!(!should_create_upgrade_backup(
|
|
false,
|
|
SharedUpgradeMode::Direct
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_copy_never_creates_second_backup() {
|
|
assert!(!should_create_upgrade_backup(
|
|
true,
|
|
SharedUpgradeMode::CopyAndUpgrade
|
|
));
|
|
}
|
|
|
|
#[cfg(not(feature = "tauri"))]
|
|
#[tokio::test]
|
|
async fn upgrade_backup_clones_authoritative_content_metadata() {
|
|
crate::event::EventState::init().await.unwrap();
|
|
let root = tempfile::tempdir().unwrap().keep();
|
|
let state = State::init_for_test(root.to_string_lossy().to_string())
|
|
.await
|
|
.unwrap();
|
|
let source = crate::api::instance::create(
|
|
"T13 Source".to_string(),
|
|
"1.21.8".to_string(),
|
|
ModLoader::Fabric,
|
|
Some("0.17.2".to_string()),
|
|
None,
|
|
InstanceLink::Unmanaged,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let source_id = source.instance.id.clone();
|
|
let source_base = state
|
|
.directories
|
|
.instances_dir()
|
|
.join(&source.instance.path);
|
|
crate::util::io::create_dir_all(source_base.join("mods"))
|
|
.await
|
|
.unwrap();
|
|
let content = [
|
|
(
|
|
"mods/sodium.jar",
|
|
b"sodium".as_slice(),
|
|
"AANobbMI",
|
|
"7pwil2dy",
|
|
crate::state::instances::ContentOwnershipKind::UserAdded,
|
|
),
|
|
(
|
|
"mods/lithium.jar",
|
|
b"lithium".as_slice(),
|
|
"gvQqBUqZ",
|
|
"qxIL7Kb8",
|
|
crate::state::instances::ContentOwnershipKind::PackManaged,
|
|
),
|
|
];
|
|
for (relative_path, bytes, project_id, release_id, ownership) in content
|
|
{
|
|
let path = source_base.join(relative_path);
|
|
crate::util::io::write(&path, bytes).await.unwrap();
|
|
let (_, sha1) =
|
|
crate::util::fetch::sha1_file_async(&path).await.unwrap();
|
|
let provider_ref = ContentProviderRef::Modrinth {
|
|
project_id: crate::state::ModrinthProjectId::new(project_id)
|
|
.unwrap(),
|
|
version_id: Some(
|
|
crate::state::ModrinthVersionId::new(release_id).unwrap(),
|
|
),
|
|
};
|
|
crate::state::record_project_file_atomic(
|
|
&source_id,
|
|
relative_path,
|
|
&sha1,
|
|
bytes.len() as u64,
|
|
crate::state::ProjectType::Mod,
|
|
crate::state::instances::ContentSourceKind::Local,
|
|
ownership,
|
|
Some(&provider_ref),
|
|
true,
|
|
None,
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let source_entries = crate::state::instances::adapters::sqlite::content_rows::get_content_entries(
|
|
&source.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let source_files = crate::state::instances::adapters::sqlite::content_rows::get_instance_files(
|
|
&source_id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let paths_by_file = source_files
|
|
.iter()
|
|
.map(|file| (file.id.as_str(), file.relative_path.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
let entry_by_path = source_entries
|
|
.iter()
|
|
.map(|entry| {
|
|
(paths_by_file[entry.file_id.as_deref().unwrap()], entry)
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
let sodium = entry_by_path["mods/sodium.jar"];
|
|
let lithium = entry_by_path["mods/lithium.jar"];
|
|
crate::state::instances::adapters::sqlite::content_rows::upsert_content_provider_ref(
|
|
&sodium.id,
|
|
&ContentProviderRef::CurseForge {
|
|
project_id: crate::state::CurseForgeProjectId::new(394468)
|
|
.unwrap(),
|
|
file_id: Some(
|
|
crate::state::CurseForgeFileId::new(6853381).unwrap(),
|
|
),
|
|
},
|
|
false,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
crate::state::instances::adapters::sqlite::content_rows::set_content_entry_auto_dependency(
|
|
&lithium.id,
|
|
true,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
crate::state::instances::commands::toggle_content_entries(
|
|
&source_id,
|
|
std::slice::from_ref(&lithium.id),
|
|
Some(false),
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut tx = state.pool.begin().await.unwrap();
|
|
let now = chrono::Utc::now();
|
|
crate::state::instances::adapters::sqlite::content_rows::upsert_content_dependency_edge_in_transaction(
|
|
&crate::state::instances::ContentDependencyEdge {
|
|
id: format!("content-dependency:{}", Uuid::new_v4()),
|
|
content_set_id: source.applied_content_set.id.clone(),
|
|
parent_entry_id: sodium.id.clone(),
|
|
child_entry_id: lithium.id.clone(),
|
|
evidence_provider: ContentProvider::Modrinth,
|
|
parent_provider: ContentProvider::Modrinth,
|
|
child_provider: ContentProvider::Modrinth,
|
|
dependency_kind: crate::state::instances::ContentDependencyKind::Required,
|
|
parent_project_id: "AANobbMI".to_string(),
|
|
parent_release_id: "7pwil2dy".to_string(),
|
|
child_project_id: "gvQqBUqZ".to_string(),
|
|
child_release_id: "qxIL7Kb8".to_string(),
|
|
created_at: now,
|
|
modified_at: now,
|
|
},
|
|
&mut tx,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
tx.commit().await.unwrap();
|
|
let job_id = Uuid::new_v4();
|
|
let mut job_state = upgrade_job_state();
|
|
job_state.target = InstallTarget::ExistingInstance {
|
|
instance_id: source_id.clone(),
|
|
};
|
|
job_state.cleanup = InstallCleanup::RestoreExistingInstance {
|
|
instance_id: source_id.clone(),
|
|
};
|
|
store::insert(job_id, &job_state, InstallJobStatus::Running, &state)
|
|
.await
|
|
.unwrap();
|
|
|
|
let backup_id =
|
|
create_upgrade_backup(job_id, &job_state, &state, &source_id, None)
|
|
.await
|
|
.unwrap();
|
|
let snapshot = crate::state::instances::commands::get_content_snapshot(
|
|
&backup_id, false, &state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let sodium_snapshot = snapshot
|
|
.items
|
|
.iter()
|
|
.find(|item| {
|
|
item.provider_project_id.as_deref() == Some("AANobbMI")
|
|
})
|
|
.unwrap();
|
|
let lithium_snapshot = snapshot
|
|
.items
|
|
.iter()
|
|
.find(|item| {
|
|
item.provider_project_id.as_deref() == Some("gvQqBUqZ")
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
sodium_snapshot.provider_release_id.as_deref(),
|
|
Some("7pwil2dy")
|
|
);
|
|
assert_eq!(
|
|
lithium_snapshot.provider_release_id.as_deref(),
|
|
Some("qxIL7Kb8")
|
|
);
|
|
assert_eq!(
|
|
sodium_snapshot.ownership_kind,
|
|
crate::state::instances::ContentOwnershipKind::UserAdded
|
|
);
|
|
assert_eq!(
|
|
lithium_snapshot.ownership_kind,
|
|
crate::state::instances::ContentOwnershipKind::PackManaged
|
|
);
|
|
assert!(
|
|
lithium_snapshot
|
|
.dependency
|
|
.as_ref()
|
|
.unwrap()
|
|
.auto_dependency
|
|
);
|
|
|
|
let backup = crate::state::get_instance(&backup_id, &state.pool)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let backup_entries = crate::state::instances::adapters::sqlite::content_rows::get_content_entries(
|
|
&backup.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let backup_files = crate::state::instances::adapters::sqlite::content_rows::get_instance_files(
|
|
&backup_id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let backup_paths = backup_files
|
|
.iter()
|
|
.map(|file| (file.id.as_str(), file.relative_path.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
let backup_by_project = backup_entries
|
|
.iter()
|
|
.map(|entry| {
|
|
let path = backup_paths[entry.file_id.as_deref().unwrap()];
|
|
(path, entry)
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
let backup_sodium = backup_by_project["mods/sodium.jar"];
|
|
let backup_lithium = backup_by_project["mods/lithium.jar.disabled"];
|
|
assert!(!backup_lithium.enabled);
|
|
let refs = crate::state::instances::adapters::sqlite::content_rows::get_content_provider_refs_with_origin(
|
|
&backup_sodium.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refs.len(), 2);
|
|
assert!(refs.iter().any(|(provider_ref, origin)| {
|
|
*origin
|
|
&& matches!(
|
|
provider_ref,
|
|
ContentProviderRef::Modrinth { project_id, version_id }
|
|
if project_id.to_string() == "AANobbMI"
|
|
&& version_id.as_ref().map(ToString::to_string).as_deref()
|
|
== Some("7pwil2dy")
|
|
)
|
|
}));
|
|
assert!(refs.iter().any(|(provider_ref, origin)| {
|
|
!origin
|
|
&& matches!(
|
|
provider_ref,
|
|
ContentProviderRef::CurseForge {
|
|
project_id,
|
|
file_id,
|
|
} if project_id.get() == 394468
|
|
&& file_id.as_ref().is_some_and(|id| id.get() == 6853381)
|
|
)
|
|
}));
|
|
let edges = crate::state::instances::adapters::sqlite::content_rows::get_content_dependency_edges(
|
|
&backup.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(edges.len(), 1);
|
|
assert_eq!(edges[0].parent_entry_id, backup_sodium.id);
|
|
assert_eq!(edges[0].child_entry_id, backup_lithium.id);
|
|
assert_eq!(edges[0].parent_release_id, "7pwil2dy");
|
|
assert_eq!(edges[0].child_release_id, "qxIL7Kb8");
|
|
}
|
|
|
|
#[cfg(not(feature = "tauri"))]
|
|
#[tokio::test]
|
|
async fn upgrade_applies_solver_and_non_solver_physical_actions() {
|
|
crate::event::EventState::init().await.unwrap();
|
|
let state = if State::initialized() {
|
|
State::get().await.unwrap()
|
|
} else {
|
|
let root = tempfile::tempdir().unwrap().keep();
|
|
State::init_for_test(root.to_string_lossy().to_string())
|
|
.await
|
|
.unwrap()
|
|
};
|
|
let instance = crate::api::instance::create(
|
|
format!("Upgrade physical actions {}", Uuid::new_v4()),
|
|
"1.21.8".to_string(),
|
|
ModLoader::Fabric,
|
|
Some("0.17.2".to_string()),
|
|
None,
|
|
InstanceLink::Unmanaged,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let instance_id = instance.instance.id.clone();
|
|
let base = state
|
|
.directories
|
|
.instances_dir()
|
|
.join(&instance.instance.path);
|
|
crate::util::io::create_dir_all(base.join("mods"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let recognized = [
|
|
(
|
|
"mods/solver-upgrade.jar",
|
|
b"old-upgrade".as_slice(),
|
|
"AANobbMI",
|
|
"7pwil2dy",
|
|
),
|
|
(
|
|
"mods/solver-keep.jar",
|
|
b"old-keep".as_slice(),
|
|
"gvQqBUqZ",
|
|
"qxIL7Kb8",
|
|
),
|
|
(
|
|
"mods/solver-disable.jar",
|
|
b"old-disable".as_slice(),
|
|
"mOgUt4GM",
|
|
"oldRelease",
|
|
),
|
|
];
|
|
for (relative_path, bytes, project_id, release_id) in recognized {
|
|
let path = base.join(relative_path);
|
|
crate::util::io::write(&path, bytes).await.unwrap();
|
|
let (_, sha1) =
|
|
crate::util::fetch::sha1_file_async(&path).await.unwrap();
|
|
crate::state::record_project_file_atomic(
|
|
&instance_id,
|
|
relative_path,
|
|
&sha1,
|
|
bytes.len() as u64,
|
|
crate::state::ProjectType::Mod,
|
|
crate::state::instances::ContentSourceKind::Local,
|
|
crate::state::instances::ContentOwnershipKind::UserAdded,
|
|
Some(&ContentProviderRef::Modrinth {
|
|
project_id: crate::state::ModrinthProjectId::new(
|
|
project_id,
|
|
)
|
|
.unwrap(),
|
|
version_id: Some(
|
|
crate::state::ModrinthVersionId::new(release_id)
|
|
.unwrap(),
|
|
),
|
|
}),
|
|
true,
|
|
None,
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
for (relative_path, bytes) in [
|
|
("mods/local-keep.jar.disabled", b"local-keep".as_slice()),
|
|
("mods/local-disable.jar", b"local-disable".as_slice()),
|
|
(
|
|
"mods/unsupported-disable.jar",
|
|
b"unsupported-disable".as_slice(),
|
|
),
|
|
(
|
|
"mods/external-disable.jar",
|
|
b"externally-modified".as_slice(),
|
|
),
|
|
] {
|
|
crate::util::io::write(&base.join(relative_path), bytes)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
let entries = crate::state::instances::adapters::sqlite::content_rows::get_content_entries(
|
|
&instance.applied_content_set.id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let files = crate::state::instances::adapters::sqlite::content_rows::get_instance_files(
|
|
&instance_id,
|
|
&state.pool,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let paths = files
|
|
.iter()
|
|
.map(|file| (file.id.as_str(), file.relative_path.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
let content_ids = entries
|
|
.iter()
|
|
.map(|entry| {
|
|
(
|
|
paths[entry.file_id.as_deref().unwrap()].to_string(),
|
|
entry.id.clone(),
|
|
)
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
let upgrade_id = content_ids["mods/solver-upgrade.jar"].clone();
|
|
let keep_id = content_ids["mods/solver-keep.jar"].clone();
|
|
let disable_id = content_ids["mods/solver-disable.jar"].clone();
|
|
|
|
let items = vec![
|
|
physical_test_item(
|
|
&upgrade_id,
|
|
"mods/solver-upgrade.jar",
|
|
crate::state::InstanceUpgradeItemStatus::UpgradeAvailable,
|
|
InstanceUpgradeAction::Upgrade,
|
|
true,
|
|
),
|
|
physical_test_item(
|
|
&keep_id,
|
|
"mods/solver-keep.jar",
|
|
crate::state::InstanceUpgradeItemStatus::NoCompatibleRelease,
|
|
InstanceUpgradeAction::Keep,
|
|
true,
|
|
),
|
|
physical_test_item(
|
|
&disable_id,
|
|
"mods/solver-disable.jar",
|
|
crate::state::InstanceUpgradeItemStatus::NoCompatibleRelease,
|
|
InstanceUpgradeAction::Disable,
|
|
true,
|
|
),
|
|
physical_test_item(
|
|
"local-keep",
|
|
"mods/local-keep.jar.disabled",
|
|
crate::state::InstanceUpgradeItemStatus::Unidentified,
|
|
InstanceUpgradeAction::Keep,
|
|
false,
|
|
),
|
|
physical_test_item(
|
|
"local-disable",
|
|
"mods/local-disable.jar",
|
|
crate::state::InstanceUpgradeItemStatus::Unidentified,
|
|
InstanceUpgradeAction::Disable,
|
|
true,
|
|
),
|
|
physical_test_item(
|
|
"unsupported-disable",
|
|
"mods/unsupported-disable.jar",
|
|
crate::state::InstanceUpgradeItemStatus::UnsupportedContentType,
|
|
InstanceUpgradeAction::Disable,
|
|
true,
|
|
),
|
|
physical_test_item(
|
|
"external-disable",
|
|
"mods/external-disable.jar",
|
|
crate::state::InstanceUpgradeItemStatus::Unidentified,
|
|
InstanceUpgradeAction::Disable,
|
|
true,
|
|
),
|
|
];
|
|
let selections = vec![
|
|
crate::state::InstanceUpgradeSelection {
|
|
content_id: upgrade_id,
|
|
provider: Some(ContentProvider::Modrinth),
|
|
project_id: Some("AANobbMI".to_string()),
|
|
current_release_id: Some("7pwil2dy".to_string()),
|
|
target_release_id: Some("vf7UgZpC".to_string()),
|
|
action: InstanceUpgradeAction::Upgrade,
|
|
enabled: true,
|
|
},
|
|
crate::state::InstanceUpgradeSelection {
|
|
content_id: keep_id,
|
|
provider: Some(ContentProvider::Modrinth),
|
|
project_id: Some("gvQqBUqZ".to_string()),
|
|
current_release_id: Some("qxIL7Kb8".to_string()),
|
|
target_release_id: None,
|
|
action: InstanceUpgradeAction::Keep,
|
|
enabled: true,
|
|
},
|
|
crate::state::InstanceUpgradeSelection {
|
|
content_id: disable_id,
|
|
provider: Some(ContentProvider::Modrinth),
|
|
project_id: Some("mOgUt4GM".to_string()),
|
|
current_release_id: Some("oldRelease".to_string()),
|
|
target_release_id: None,
|
|
action: InstanceUpgradeAction::Disable,
|
|
enabled: false,
|
|
},
|
|
];
|
|
let execution = physical_test_execution(items, selections);
|
|
let staged_path = state
|
|
.directories
|
|
.caches_dir()
|
|
.join(format!("upgrade-test-{}.jar", Uuid::new_v4()));
|
|
crate::util::io::create_dir_all(
|
|
staged_path.parent().expect("staged path has parent"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
crate::util::io::write(&staged_path, b"target-upgrade")
|
|
.await
|
|
.unwrap();
|
|
let (_, staged_sha1) =
|
|
crate::util::fetch::sha1_file_async(&staged_path)
|
|
.await
|
|
.unwrap();
|
|
let staged = vec![StagedUpgradeMutation {
|
|
existing_path: Some("mods/solver-upgrade.jar".to_string()),
|
|
target_path: "mods/solver-upgrade.jar".to_string(),
|
|
ownership: crate::state::instances::ContentOwnershipKind::UserAdded,
|
|
auto_dependency: false,
|
|
enabled: true,
|
|
download: StagedUpgradeDownload::Modrinth(
|
|
crate::state::instances::commands::DownloadedProjectVersion {
|
|
file_name: "solver-upgrade.jar".to_string(),
|
|
path: staged_path,
|
|
sha1: staged_sha1,
|
|
size: b"target-upgrade".len() as u64,
|
|
project_type: crate::state::ProjectType::Mod,
|
|
project_id: "AANobbMI".to_string(),
|
|
version_id: "vf7UgZpC".to_string(),
|
|
},
|
|
),
|
|
}];
|
|
|
|
apply_upgrade_content(
|
|
&instance_id,
|
|
staged,
|
|
&execution,
|
|
&HashSet::from(["mods/external-disable.jar".to_string()]),
|
|
&[],
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
tokio::fs::read(base.join("mods/solver-upgrade.jar"))
|
|
.await
|
|
.unwrap(),
|
|
b"target-upgrade"
|
|
);
|
|
assert_eq!(
|
|
tokio::fs::read(base.join("mods/solver-keep.jar"))
|
|
.await
|
|
.unwrap(),
|
|
b"old-keep"
|
|
);
|
|
assert!(base.join("mods/solver-disable.jar.disabled").exists());
|
|
assert!(base.join("mods/local-keep.jar.disabled").exists());
|
|
assert!(base.join("mods/local-disable.jar.disabled").exists());
|
|
assert!(base.join("mods/unsupported-disable.jar.disabled").exists());
|
|
assert!(base.join("mods/external-disable.jar").exists());
|
|
assert!(!base.join("mods/external-disable.jar.disabled").exists());
|
|
assert_eq!(
|
|
tokio::fs::read(base.join("mods/external-disable.jar"))
|
|
.await
|
|
.unwrap(),
|
|
b"externally-modified"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_external_add_is_classified() {
|
|
assert_eq!(
|
|
classify_upgrade_external_change(false, true),
|
|
InstanceUpgradeExternalChangeKind::Added
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_external_remove_is_classified() {
|
|
assert_eq!(
|
|
classify_upgrade_external_change(true, false),
|
|
InstanceUpgradeExternalChangeKind::Removed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_external_modify_is_classified() {
|
|
assert_eq!(
|
|
classify_upgrade_external_change(true, true),
|
|
InstanceUpgradeExternalChangeKind::Modified
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_full_scan_detects_added_file() {
|
|
let changes = diff_upgrade_source_files(
|
|
&[],
|
|
&[upgrade_source_file("mods/new.jar", "new", true)],
|
|
);
|
|
assert_eq!(changes.len(), 1);
|
|
assert_eq!(changes[0].kind, InstanceUpgradeExternalChangeKind::Added);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_full_scan_detects_removed_file() {
|
|
let changes = diff_upgrade_source_files(
|
|
&[upgrade_source_file("mods/old.jar", "old", true)],
|
|
&[],
|
|
);
|
|
assert_eq!(changes.len(), 1);
|
|
assert_eq!(changes[0].kind, InstanceUpgradeExternalChangeKind::Removed);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_full_scan_detects_enabled_state_change() {
|
|
let changes = diff_upgrade_source_files(
|
|
&[upgrade_source_file("mods/mod.jar", "same", true)],
|
|
&[upgrade_source_file("mods/mod.jar", "same", false)],
|
|
);
|
|
assert_eq!(changes.len(), 1);
|
|
assert_eq!(
|
|
changes[0].kind,
|
|
InstanceUpgradeExternalChangeKind::Modified
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_reports_external_edit_after_launcher_mutation() {
|
|
let source = upgrade_source_file("mods/lithium.jar", "old", true);
|
|
let expected = upgrade_source_file("mods/lithium.jar", "target", true);
|
|
let current = upgrade_source_file("mods/lithium.jar", "user", true);
|
|
let changes = final_upgrade_external_changes(
|
|
&[source],
|
|
&[current],
|
|
&HashMap::from([("mods/lithium.jar".to_string(), Some(expected))]),
|
|
);
|
|
|
|
assert_eq!(changes.len(), 1);
|
|
assert_eq!(changes[0].relative_path, "mods/lithium.jar");
|
|
assert_eq!(
|
|
changes[0].kind,
|
|
InstanceUpgradeExternalChangeKind::Modified
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_reports_external_edit_of_skipped_mutation() {
|
|
let changes = final_upgrade_external_changes(
|
|
&[upgrade_source_file("mods/sodium.jar", "old", true)],
|
|
&[upgrade_source_file("mods/sodium.jar", "user", true)],
|
|
&HashMap::new(),
|
|
);
|
|
|
|
assert_eq!(changes.len(), 1);
|
|
assert_eq!(changes[0].relative_path, "mods/sodium.jar");
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_reports_external_add_but_not_launcher_write() {
|
|
let changes = final_upgrade_external_changes(
|
|
&[],
|
|
&[
|
|
upgrade_source_file("mods/dependency.jar", "target", true),
|
|
upgrade_source_file(
|
|
"mods/t16-external-added.jar",
|
|
"user",
|
|
true,
|
|
),
|
|
],
|
|
&HashMap::from([(
|
|
"mods/dependency.jar".to_string(),
|
|
Some(upgrade_source_file(
|
|
"mods/dependency.jar",
|
|
"target",
|
|
true,
|
|
)),
|
|
)]),
|
|
);
|
|
|
|
assert_eq!(changes.len(), 1);
|
|
assert_eq!(changes[0].relative_path, "mods/t16-external-added.jar");
|
|
assert_eq!(changes[0].kind, InstanceUpgradeExternalChangeKind::Added);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_external_changes_coalesce_with_skipped_conflicts() {
|
|
let mut changes = vec![InstanceUpgradeExternalChange {
|
|
relative_path: "mods/sodium.jar".to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Modified,
|
|
}];
|
|
merge_upgrade_external_changes(
|
|
&mut changes,
|
|
vec![
|
|
InstanceUpgradeExternalChange {
|
|
relative_path: "mods/sodium.jar".to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Modified,
|
|
},
|
|
InstanceUpgradeExternalChange {
|
|
relative_path: "mods/t16-external-added.jar".to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Added,
|
|
},
|
|
],
|
|
);
|
|
|
|
assert_eq!(changes.len(), 2);
|
|
assert!(changes.iter().any(|change| {
|
|
change.relative_path == "mods/sodium.jar"
|
|
&& change.kind == InstanceUpgradeExternalChangeKind::Modified
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_external_target_conflict_skips_mutation() {
|
|
let mutation =
|
|
test_upgrade_mutation(Some("mods/old.jar"), "mods/new.jar");
|
|
assert!(upgrade_mutation_conflicts(
|
|
&mutation,
|
|
&HashSet::from(["mods/new.jar".to_string()])
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_external_delete_conflict_skips_mutation() {
|
|
let mutation =
|
|
test_upgrade_mutation(Some("mods/old.jar"), "mods/new.jar");
|
|
assert!(upgrade_mutation_conflicts(
|
|
&mutation,
|
|
&HashSet::from(["mods/old.jar".to_string()])
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_unrelated_external_change_does_not_skip_mutation() {
|
|
let mutation =
|
|
test_upgrade_mutation(Some("mods/old.jar"), "mods/new.jar");
|
|
assert!(!upgrade_mutation_conflicts(
|
|
&mutation,
|
|
&HashSet::from(["mods/user.jar".to_string()])
|
|
));
|
|
}
|
|
|
|
fn test_upgrade_mutation(
|
|
existing_path: Option<&str>,
|
|
target_path: &str,
|
|
) -> StagedUpgradeMutation {
|
|
StagedUpgradeMutation {
|
|
existing_path: existing_path.map(ToString::to_string),
|
|
target_path: target_path.to_string(),
|
|
ownership: crate::state::instances::ContentOwnershipKind::UserAdded,
|
|
auto_dependency: false,
|
|
enabled: true,
|
|
download: StagedUpgradeDownload::Modrinth(
|
|
crate::state::instances::commands::DownloadedProjectVersion {
|
|
file_name: "new.jar".to_string(),
|
|
path: PathBuf::from("new.jar"),
|
|
sha1: "sha1".to_string(),
|
|
size: 1,
|
|
project_type: crate::state::ProjectType::Mod,
|
|
project_id: "project".to_string(),
|
|
version_id: "version".to_string(),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_staging_populates_persisted_download_summary() {
|
|
let staged = (0..27)
|
|
.map(|index| {
|
|
test_upgrade_mutation(
|
|
Some(&format!("mods/old-{index}.jar")),
|
|
&format!("mods/target-{index}.jar"),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let mut job = InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentDownloadStarted {
|
|
files: staged.len() as u64,
|
|
bytes: Some(staged.len() as u64),
|
|
});
|
|
for mutation in staged {
|
|
job.record_event(InstallJobEventKind::ContentFileCompleted {
|
|
path: mutation.target_path,
|
|
bytes: 1,
|
|
});
|
|
}
|
|
|
|
let persisted = serde_json::to_string(&job).unwrap();
|
|
let restored: InstallJobState =
|
|
serde_json::from_str(&persisted).unwrap();
|
|
let summary = restored.download_summary();
|
|
assert_eq!(summary.files_completed, 27);
|
|
assert_eq!(summary.files_total, Some(27));
|
|
assert_eq!(summary.bytes_downloaded, 27);
|
|
assert_eq!(summary.bytes_total, Some(27));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upgrade_staging_scheduler_enters_requests_concurrently() {
|
|
use std::sync::Arc;
|
|
use tokio::sync::Barrier;
|
|
|
|
let barrier = Arc::new(Barrier::new(2));
|
|
let mut downloads = (0..2)
|
|
.map(|index| {
|
|
let barrier = barrier.clone();
|
|
async move {
|
|
barrier.wait().await;
|
|
Ok::<_, crate::Error>((index, index))
|
|
}
|
|
})
|
|
.collect::<FuturesUnordered<_>>();
|
|
|
|
assert_eq!(
|
|
collect_ordered_upgrade_staging(&mut downloads)
|
|
.await
|
|
.unwrap(),
|
|
vec![0, 1]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upgrade_staging_scheduler_restores_request_order() {
|
|
let mut downloads = [2_usize, 0, 1]
|
|
.into_iter()
|
|
.map(|index| async move {
|
|
Ok::<_, crate::Error>((index, format!("mutation-{index}")))
|
|
})
|
|
.collect::<FuturesUnordered<_>>();
|
|
|
|
assert_eq!(
|
|
collect_ordered_upgrade_staging(&mut downloads)
|
|
.await
|
|
.unwrap(),
|
|
vec!["mutation-0", "mutation-1", "mutation-2"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upgrade_staging_scheduler_returns_first_error() {
|
|
let mut downloads = [
|
|
Ok((0, "first")),
|
|
Err(crate::ErrorKind::InputError("failed".into()).into()),
|
|
]
|
|
.into_iter()
|
|
.map(std::future::ready)
|
|
.collect::<FuturesUnordered<_>>();
|
|
|
|
assert!(
|
|
collect_ordered_upgrade_staging(&mut downloads)
|
|
.await
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[test]
|
|
fn instance_upgrade_debug_hook_ignores_invalid_and_zero_counts() {
|
|
assert_eq!(debug_mutation_count(""), None);
|
|
assert_eq!(debug_mutation_count("invalid"), None);
|
|
assert_eq!(debug_mutation_count("0"), None);
|
|
assert_eq!(debug_mutation_count(" 2 "), Some(2));
|
|
}
|
|
|
|
fn upgrade_source_file(
|
|
relative_path: &str,
|
|
sha1: &str,
|
|
enabled: bool,
|
|
) -> crate::state::InstanceUpgradeSourceFile {
|
|
crate::state::InstanceUpgradeSourceFile {
|
|
relative_path: relative_path.to_string(),
|
|
sha1: sha1.to_string(),
|
|
size: 1,
|
|
enabled,
|
|
}
|
|
}
|
|
}
|