use super::events::{InstallProgressReporter, emit_install_job}; use super::model::{ InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay, InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus, InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit, InstallProgress, InstallRequest, InstallRollbackState, InstallTarget, SharedInstanceInstallData, }; use super::shared_instance::{ apply_shared_instance_content, apply_shared_instance_update, attach_pending_shared_instance, finalize_shared_instance_attachment, shared_instance_link, shared_instance_pack_location, }; 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, get_local_pack_instance, }; use crate::api::pack::install_mrpack::install_zipped_mrpack_files_with_reporter; use crate::event::InstancePayloadType; use crate::event::emit::emit_instance; use crate::state::instances::adapters::sqlite::content_rows; use crate::state::{ ContentSourceKind, InstanceInstallStage, InstanceLink, ModLoader, State, }; use crate::util::fetch::DownloadReason; use std::collections::HashSet; use std::path::PathBuf; use uuid::Uuid; pub async fn create_instance( name: String, game_version: String, loader: ModLoader, loader_version: Option, icon_path: Option, link: InstanceLink, ) -> crate::Result { start(InstallRequest::CreateInstance { name, game_version, loader, loader_version, icon_path, link, }) .await } pub async fn create_modpack_instance( location: CreatePackLocation, post_install_edit: Option, ) -> crate::Result { start(InstallRequest::CreateModpackInstance { location, post_install_edit, }) .await } pub async fn create_shared_instance( data: SharedInstanceInstallData, ) -> crate::Result { start(InstallRequest::CreateSharedInstance { data }).await } pub async fn update_shared_instance( instance_id: String, data: SharedInstanceInstallData, ) -> crate::Result { start(InstallRequest::UpdateSharedInstance { instance_id, data }).await } pub async fn import_instance( launcher_type: crate::api::pack::import::ImportLauncherType, base_path: PathBuf, instance_folder: String, ) -> crate::Result { start(InstallRequest::ImportInstance { launcher_type, base_path, instance_folder, }) .await } pub async fn duplicate_instance( source_instance_id: String, ) -> crate::Result { start(InstallRequest::DuplicateInstance { source_instance_id }).await } pub async fn install_existing_instance( instance_id: String, force: bool, ) -> crate::Result { start(InstallRequest::InstallExistingInstance { instance_id, force }).await } pub async fn install_pack_to_existing_instance( instance_id: String, location: CreatePackLocation, post_install_edit: Option, ) -> crate::Result { start(InstallRequest::InstallPackToExistingInstance { instance_id, location, post_install_edit, }) .await } pub async fn list_jobs( include_finished: bool, ) -> crate::Result> { 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 { 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 { 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 { 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()); } if job.state.rollback_error.is_some() { recovery::apply_cleanup(&job.state, &state).await?; recovery::clear_staging_dir(&job.state).await; } job.state.target = job.state.request.target(); job.state.cleanup = job.state.request.cleanup(); job.state.rollback = None; job.state.paths.staging_dir = None; job.state.error = None; job.state.rollback_error = None; job.state.context = None; job.state.progress.phase = InstallPhaseId::PreparingInstance; job.state.progress.progress = None; job.state.progress.details = InstallPhaseDetails::Empty; job.state.record_event(InstallJobEventKind::JobQueued { kind: job.state.request.kind(), }); let record = match store::update_status( job_id, InstallJobStatus::Queued, &job.state, &state, ) .await { Ok(record) => record, Err(error) => { if let Err(cleanup_error) = recovery::apply_cleanup(&job.state, &state).await { tracing::error!( "Error cleaning up unqueued install job {job_id}: {cleanup_error}" ); } return Err(error); } }; emit_install_job(&record.snapshot()).await?; if let Err(error) = prepare_initial_instance(&mut job.state, &state).await { let error_view = install_error_view( job.state.progress.phase, &error, job.state.context.clone(), ); if let Err(terminal_error) = terminalize_failed_job(job_id, job.state, error_view, &state).await { tracing::error!( "Failed to terminalize retried install job {job_id}: {terminal_error}" ); } return Err(error); } let record = match store::update_state(job_id, &job.state, &state).await { Ok(record) => record, Err(error) => { let error_view = install_error_view( job.state.progress.phase, &error, job.state.context.clone(), ); if let Err(terminal_error) = terminalize_failed_job(job_id, job.state, error_view, &state) .await { tracing::error!( "Failed to terminalize retried install job {job_id}: {terminal_error}" ); } return Err(error); } }; if let Err(error) = lock_existing_instance_if_needed(&job.state, &state).await { let error_view = install_error_view( job.state.progress.phase, &error, job.state.context.clone(), ); if let Err(terminal_error) = terminalize_failed_job(job_id, job.state, error_view, &state).await { tracing::error!( "Failed to terminalize retried install job {job_id}: {terminal_error}" ); } return Err(error); } emit_install_job(&record.snapshot()).await?; spawn_job(job_id); Ok(record.snapshot()) } pub async fn cancel_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 Err(crate::ErrorKind::InputError( "Only queued install jobs can be canceled".to_string(), ) .into()); } let canceled_phase = job.state.progress.phase; job.state.error = Some(InstallErrorView::from_message( "canceled", canceled_phase, "Install was canceled", )); job.state.record_event(InstallJobEventKind::JobCanceled { phase: canceled_phase, }); job.state .record_event(InstallJobEventKind::RollbackStarted { cleanup: job.state.cleanup.clone(), }); let status_updated = store::update_status_if( job_id, InstallJobStatus::Queued, InstallJobStatus::Running, &job.state, &state, ) .await? .is_some(); if !status_updated { return Err(crate::ErrorKind::InputError( "Install job is no longer queued".to_string(), ) .into()); } let cleanup_succeeded = match recovery::apply_cleanup(&job.state, &state).await { Ok(()) => { job.state .record_event(InstallJobEventKind::RollbackCompleted); clear_deleted_new_instance_id(&mut job.state); 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 } }; let status = if cleanup_succeeded { InstallJobStatus::Canceled } else { InstallJobStatus::Failed }; let record = store::update_status(job_id, status, &job.state, &state).await?; if cleanup_succeeded { recovery::clear_staging_dir(&job.state).await; } emit_install_job(&record.snapshot()).await?; Ok(record.snapshot()) } pub(crate) async fn cancel_jobs_for_instance_deletion( instance_id: &str, state: &State, ) -> crate::Result<()> { for mut job in store::list_active_for_instance(instance_id, state).await? { let canceled_phase = job.state.progress.phase; job.state.error = Some(InstallErrorView::from_message( "canceled", canceled_phase, "Install canceled because the instance was deleted", )); job.state.record_event(InstallJobEventKind::JobCanceled { phase: canceled_phase, }); let Some(record) = store::finish_active( job.id, InstallJobStatus::Canceled, &job.state, state, ) .await? else { continue; }; store::dismiss(job.id, state).await?; emit_install_job(&record.snapshot()).await?; } Ok(()) } pub async fn dismiss_job(job_id: Uuid) -> crate::Result<()> { let state = State::get().await?; store::dismiss(job_id, &state).await } async fn start(request: InstallRequest) -> crate::Result { let state = State::get().await?; let id = Uuid::new_v4(); let mut job_state = InstallJobState::new(request); set_initial_display(&mut job_state); let record = store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?; emit_install_job(&record.snapshot()).await?; if let Err(error) = prepare_initial_instance(&mut job_state, &state).await { let error_view = install_error_view( job_state.progress.phase, &error, job_state.context.clone(), ); if let Err(terminal_error) = terminalize_failed_job(id, job_state, error_view, &state).await { tracing::error!( "Failed to terminalize install job {id} after setup error: {terminal_error}" ); } return Err(error); } let record = match store::update_state(id, &job_state, &state).await { Ok(record) => record, Err(error) => { let error_view = install_error_view( job_state.progress.phase, &error, job_state.context.clone(), ); if let Err(terminal_error) = terminalize_failed_job(id, job_state, error_view, &state).await { tracing::error!( "Failed to terminalize install job {id} after setup error: {terminal_error}" ); } return Err(error); } }; if let Err(error) = lock_existing_instance_if_needed(&job_state, &state).await { let error_view = install_error_view( job_state.progress.phase, &error, job_state.context.clone(), ); if let Err(terminal_error) = terminalize_failed_job(id, job_state, error_view, &state).await { tracing::error!( "Failed to terminalize install job {id} after setup error: {terminal_error}" ); } return Err(error); } emit_install_job(&record.snapshot()).await?; spawn_job(id); Ok(record.snapshot()) } async fn prepare_initial_instance( job_state: &mut InstallJobState, state: &State, ) -> crate::Result<()> { match job_state.request.clone() { InstallRequest::CreateInstance { name, game_version, loader, loader_version, icon_path, link, } => { let metadata = crate::api::instance::create( name, game_version, loader, loader_version, icon_path, link, ) .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 = match location { CreatePackLocation::FromFile { path } => { get_local_pack_instance(&path) } location => 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, ) .await?; set_display( job_state, metadata.instance.name, metadata.instance.icon_path, ); set_instance_id(job_state, metadata.instance.id); } InstallRequest::CreateSharedInstance { data } => { let shared_link = shared_instance_link(data.modpack.as_ref()); let (game_version, loader, loader_version, icon_path) = if let Some(modpack) = data.modpack.clone() { let preview = get_instance_from_pack( shared_instance_pack_location(modpack), ) .await?; ( preview.game_version, preview.modloader, preview.loader_version, data.instance_icon_url .clone() .or_else(|| { preview.icon.as_ref().map(|path| { path.to_string_lossy().to_string() }) }) .or_else(|| preview.icon_url.clone()), ) } else { ( data.game_version.clone(), data.loader, data.loader_version.clone(), data.instance_icon_url.clone(), ) }; let metadata = crate::api::instance::create( data.name.clone(), game_version, loader, loader_version, icon_path, shared_link, ) .await?; set_display( job_state, metadata.instance.name, metadata.instance.icon_path, ); let instance_id = metadata.instance.id; set_instance_id(job_state, instance_id.clone()); attach_pending_shared_instance(&instance_id, &data, state).await?; emit_instance(&instance_id, InstancePayloadType::Edited).await?; } InstallRequest::ImportInstance { instance_folder, .. } => { let metadata = crate::api::instance::create( instance_folder, "1.19.4".to_string(), ModLoader::Vanilla, Some("latest".to_string()), None, InstanceLink::Unmanaged, ) .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, ) .await?; set_display( job_state, created.instance.name, created.instance.icon_path, ); set_instance_id(job_state, created.instance.id); } InstallRequest::InstallExistingInstance { instance_id, .. } | InstallRequest::InstallPackToExistingInstance { instance_id, .. } | InstallRequest::UpdateSharedInstance { instance_id, .. } => { prepare_existing_rollback(job_state, state, &instance_id).await?; } } Ok(()) } fn spawn_job(job_id: Uuid) { tokio::spawn(async move { if let Err(error) = Box::pin(run_job(job_id)).await { let failure = error.to_string(); tracing::error!("Install job {job_id} terminated: {failure}"); if let Err(error) = terminalize_stranded_job(job_id, failure).await { tracing::error!( "Failed to terminalize stranded install job {job_id}: {error}" ); } } }); } 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(()); }; emit_install_job(&record.snapshot()).await?; let result = Box::pin(run_request(job_id, &mut job_state, &state)).await; if let Ok(record) = store::get_required(job_id, &state).await { let status = record.status; job_state = record.state; if status != InstallJobStatus::Running { return Ok(()); } } let result = match result { Ok(Some(instance_id)) => { set_instance_id(&mut job_state, instance_id.clone()); Ok(instance_id) } Ok(None) => Err(crate::ErrorKind::InputError( "Install job completed without an instance id".to_string(), ) .into()), Err(error) => Err(error), }; match result { Ok(instance_id) => { job_state.record_event(InstallJobEventKind::JobSucceeded { instance_id: Some(instance_id.clone()), }); job_state.progress.phase = InstallPhaseId::Finalizing; job_state.progress.progress = None; job_state.progress.details = InstallPhaseDetails::Empty; job_state.error = None; job_state.rollback_error = None; job_state.context = None; if let Some(record) = store::complete_success(job_id, &job_state, &state).await? { recovery::clear_staging_dir(&job_state).await; if let Err(error) = emit_instance(&instance_id, InstancePayloadType::Edited) .await { tracing::warn!( "Failed to emit completed instance {instance_id}: {error}" ); } emit_install_job(&record.snapshot()).await?; } } Err(error) => { tracing::error!("Install job {job_id} failed: {error}"); let error_view = install_error_view( job_state.progress.phase, &error, job_state.context.clone(), ); terminalize_failed_job(job_id, job_state, error_view, &state) .await?; } } Ok(()) } async fn terminalize_stranded_job( job_id: Uuid, message: String, ) -> crate::Result<()> { let state = State::get().await?; let job = store::get_required(job_id, &state).await?; if !matches!( job.status, InstallJobStatus::Queued | InstallJobStatus::Running ) { return Ok(()); } let error_view = InstallErrorView::from_message( "install_worker_terminated", job.state.progress.phase, message, ); terminalize_failed_job(job_id, job.state, error_view, &state).await } async fn terminalize_failed_job( job_id: Uuid, mut job_state: InstallJobState, error_view: InstallErrorView, state: &State, ) -> crate::Result<()> { let failed_phase = job_state.progress.phase; 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.rollback_error = None; 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 { Ok(()) => { job_state.record_event(InstallJobEventKind::RollbackCompleted); clear_deleted_new_instance_id(&mut job_state); true } 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 } }; if let Some(record) = store::finish_active( job_id, InstallJobStatus::Failed, &job_state, state, ) .await? { if cleanup_succeeded { recovery::clear_staging_dir(&job_state).await; } emit_install_job(&record.snapshot()).await?; } Ok(()) } async fn run_request( job_id: Uuid, job_state: &mut InstallJobState, state: &State, ) -> crate::Result> { match job_state.request.clone() { InstallRequest::CreateInstance { name, game_version, loader, loader_version: _, icon_path: _, link: _, } => { 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?; update_progress( job_id, job_state, state, InstallPhaseId::DownloadingMinecraft, InstallPhaseDetails::Minecraft { game_version, 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(InstallProgressReporter::new(job_id, job_state.clone())), ) .await?; Ok(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?; Box::pin(install_pack( job_id, job_state, location, instance_id.clone(), DownloadReason::Modpack, )) .await?; apply_post_install_edit(&instance_id, post_install_edit).await?; Ok(Some(instance_id)) } InstallRequest::CreateSharedInstance { data } => { 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()); }; Box::pin(apply_shared_instance_content( job_id, job_state, state, &instance_id, &data, )) .await?; finalize_shared_instance_attachment(&instance_id, &data, state) .await?; emit_instance(&instance_id, InstancePayloadType::Edited).await?; Ok(Some(instance_id)) } InstallRequest::ImportInstance { launcher_type, base_path, instance_folder, } => { 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, InstallProgressReporter::new(job_id, job_state.clone()), ) .await?; Ok(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())), ) .await?; emit_instance(&instance_id, InstancePayloadType::Edited).await?; Ok(Some(instance_id)) } InstallRequest::InstallExistingInstance { instance_id, force } => { prepare_existing_rollback(job_state, state, &instance_id).await?; lock_existing_instance(&instance_id, state).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())), ) .await?; Ok(Some(instance_id)) } InstallRequest::InstallPackToExistingInstance { instance_id, location, post_install_edit, } => { prepare_existing_rollback(job_state, state, &instance_id).await?; lock_existing_instance(&instance_id, state).await?; let disabled_project_ids = remove_existing_pack_content( job_id, job_state, state, &instance_id, ) .await?; Box::pin(install_pack( job_id, job_state, location, instance_id.clone(), DownloadReason::Modpack, )) .await?; restore_disabled_projects( &instance_id, disabled_project_ids, state, ) .await?; apply_post_install_edit(&instance_id, post_install_edit).await?; Ok(Some(instance_id)) } InstallRequest::UpdateSharedInstance { instance_id, data } => { prepare_existing_rollback(job_state, state, &instance_id).await?; lock_existing_instance(&instance_id, state).await?; let rollback_instance = job_state .rollback .as_ref() .map(|rollback| rollback.instance.clone()) .ok_or_else(|| { crate::ErrorKind::OtherError( "Shared instance update rollback state is missing" .to_string(), ) })?; let staging_dir = recovery::prepare_shared_instance_update_backup( job_id, &rollback_instance, state, ) .await?; job_state.paths.staging_dir = Some(staging_dir); let record = store::update_state(job_id, job_state, state).await?; emit_install_job(&record.snapshot()).await?; let disabled_project_ids = disabled_project_ids(&instance_id, state).await?; Box::pin(apply_shared_instance_update( job_id, job_state, state, &instance_id, &data, )) .await?; restore_disabled_projects( &instance_id, disabled_project_ids, state, ) .await?; finalize_shared_instance_attachment(&instance_id, &data, state) .await?; emit_instance(&instance_id, InstancePayloadType::Edited).await?; Ok(Some(instance_id)) } } } async fn apply_post_install_edit( instance_id: &str, edit: Option, ) -> 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?; emit_instance(instance_id, InstancePayloadType::Edited).await?; Ok(()) } async fn disabled_project_ids( instance_id: &str, state: &State, ) -> crate::Result> { Ok(crate::state::instances::commands::list_project_files( instance_id, state, ) .await? .into_iter() .filter_map(|file| (!file.enabled).then_some(file.project_id?)) .collect()) } async fn remove_existing_pack_content( job_id: Uuid, job_state: &InstallJobState, state: &State, instance_id: &str, ) -> crate::Result> { 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 { .. } => { remove_existing_imported_pack_content( instance_id, &metadata, state, ) .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_some(file.project_id?)) .collect::>(); 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?; crate::api::pack::install_mrpack::remove_all_related_files( instance_id.to_string(), old_pack.file, ) .await?; Ok(disabled_project_ids) } async fn remove_existing_imported_pack_content( instance_id: &str, metadata: &crate::state::InstanceMetadata, state: &State, ) -> crate::Result<()> { let _content_lock = state.lock_instance_content(instance_id).await; let entries = content_rows::get_content_entries( &metadata.applied_content_set.id, &state.pool, ) .await?; let files = content_rows::get_instance_files(instance_id, &state.pool) .await? .into_iter() .map(|file| (file.id.clone(), file)) .collect::>(); let base = state .directories .instances_dir() .join(&metadata.instance.path); let mut removed_file_ids = HashSet::new(); for entry in entries { if !matches!( entry.source_kind, ContentSourceKind::ImportedModpack | ContentSourceKind::ModrinthModpack ) { continue; } let Some(file_id) = entry.file_id else { continue; }; if !removed_file_ids.insert(file_id.clone()) { continue; } let Some(file) = files.get(&file_id) else { continue; }; crate::util::io::remove_file(base.join(&file.relative_path)).await?; let mut tx = state.pool.begin().await?; content_rows::remove_content_entries_for_file( &metadata.applied_content_set.id, &file.id, &mut tx, ) .await?; content_rows::remove_instance_file_by_relative_path( instance_id, &file.relative_path, &mut tx, ) .await?; tx.commit().await?; } Ok(()) } async fn restore_disabled_projects( instance_id: &str, disabled_project_ids: HashSet, 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? { if file.enabled && let Some(project_id) = &file.project_id && disabled_project_ids.contains(project_id) { crate::state::instances::commands::toggle_disable_project( instance_id, &file.relative_path, Some(false), state, ) .await?; } } Ok(()) } pub(super) async fn install_pack( job_id: Uuid, job_state: &mut InstallJobState, location: CreatePackLocation, instance_id: String, reason: DownloadReason, ) -> crate::Result<()> { 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?; generate_pack_from_file(path, instance_id.clone()).await? } }; Box::pin(install_zipped_mrpack_files_with_reporter( create_pack, false, reason, reporter, )) .await?; Ok(()) } 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}" )) })?; if instance.quarantined { return Err(crate::ErrorKind::InputError( "Content in quarantined instances cannot be changed.".to_string(), ) .into()); } 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, }); job_state.cleanup = InstallCleanup::RestoreExistingInstance { instance_id: instance_id.to_string(), }; Ok(()) } async fn lock_existing_instance_if_needed( job_state: &InstallJobState, state: &State, ) -> crate::Result<()> { if let InstallCleanup::RestoreExistingInstance { instance_id } = &job_state.cleanup { lock_existing_instance(instance_id, state).await?; } Ok(()) } async fn lock_existing_instance( instance_id: &str, state: &State, ) -> crate::Result<()> { crate::state::instances::commands::set_instance_install_stage( instance_id, InstanceInstallStage::MinecraftInstalling, &state.pool, ) .await?; emit_instance(instance_id, InstancePayloadType::Edited).await?; Ok(()) } pub(super) 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(()) } pub(super) async fn update_content_progress( job_id: Uuid, job_state: &mut InstallJobState, state: &State, current: u64, total: u64, ) -> crate::Result<()> { job_state.progress.phase = InstallPhaseId::DownloadingContent; job_state.progress.progress = Some(InstallProgress { current, total, secondary: None, }); job_state.progress.details = InstallPhaseDetails::Empty; 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), } } }; } 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, ) { job_state.display = Some(InstallJobDisplay { title, icon }); } fn set_initial_display(job_state: &mut InstallJobState) { let display = match &job_state.request { InstallRequest::CreateModpackInstance { location, .. } => { match location { CreatePackLocation::FromVersionId { title, icon_url, .. } => Some((title.clone(), icon_url.clone())), CreatePackLocation::FromFile { path } => { Some((get_local_pack_instance(path).name, None)) } } } _ => None, }; if let Some((title, icon)) = display { set_display(job_state, title, icon); } } fn install_error_view( phase: InstallPhaseId, error: &crate::Error, context: Option, ) -> InstallErrorView { let mut view = InstallErrorView::from_error( install_error_code(phase, error), phase, error, context, ); if let ErrorKind::SharedInstanceUnavailable(reason) = error.raw.as_ref() { view.reason = Some(*reason); } view } fn install_error_code( phase: InstallPhaseId, error: &crate::Error, ) -> &'static str { use InstallPhaseId::*; match error.raw.as_ref() { ErrorKind::SharedInstanceUnavailable(_) => { "shared_instance_unavailable" } ErrorKind::SharedInstancesApiError(_) => "shared_instances_api_error", ErrorKind::InputError(_) => match phase { PreparingInstance | Finalizing => "instance_error", ResolvingPack | DownloadingPackFile | ReadingPackManifest => { "pack_error" } DownloadingContent => "content_error", ExtractingOverrides => "path_error", PreparingJava => "java_error", DownloadingMinecraft => "instance_error", RollingBack => "rollback_error", ResolvingMinecraft | ResolvingLoader | RunningLoaderProcessors => { "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::ApiIsDownError(_) | ErrorKind::WSError(_) | ErrorKind::WSClosedError(_) | ErrorKind::Ratelimited { .. } => "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 { match &job_state.target { InstallTarget::NewInstance { instance_id } => instance_id.clone(), InstallTarget::ExistingInstance { instance_id } => { Some(instance_id.clone()) } } } pub(super) 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, }, } }