mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 17:44:50 +00:00
feat: instances v2 (#6431)
* feat: base of instances v2 * feat: use old profiles with compat layer * prototype: instances v2 * fix: install_from using profile * fix: skins migration fix * fix: frontend still using profile path * fix: add update proj multiselect guard * fix: cargo fmt * fix: content missing fields * feat: break up app-lib/api/instance.rs * fix: check_content_updates mismatch * fix: updater modal cleanup w/new structure * feat: better update all handling * fix: remove preview_update_all * fix: feedback on bulk update + lint * fix: rem transitions * fix: change to jsonb * feat: app db backup after update * fix: lint * fix: sqlx prepare + use sqlx macros * fix: lint * fix: bugs * feat: defuck the installing process up * fix: bug of hell * fix: shear * fix: fmt * fix: install progress spacing + change mc/content/overrides to bytes * fix: lint * fix: prepr * fix: navtabs anim not working in app * fix: worlds.vue improvements + browse page fixes * feat: optimise queries + adapter fns * fix: lint * fix: lint * feat: shared modrinth-content-management crate (#6469) * feat: disable warnings setting * feat: add instances shortcuts (#6329) * Add modrinth://launch deep link to start a profile Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck. * Change route to /launch/profile/{id} for future extensibility * fix: ensure profile path is url decoded * fix: URL-decode profile path from deep link * fix: use urlencoding crate for URL decoding * feat: implement app instance shortcuts * feat: change windows shortcut creation to use windows api instead * feat: implement creating a shortcut launching world/server * format * fmt * fix multiline inline tables * pnpm prepr * feat: move create shortcut to last item * refactor: split up shortcuts.rs for individual platforms * refactor: turn profile launch url into url type * use string literal and add safety comment * pt2 * refactor: rename anything that's profile into instance * update mac shortcut --------- Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com> --------- Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com> Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
This commit is contained in:
co-authored by
DJCheesusReal
Truman Gao
parent
ef4044534f
commit
734720e11e
@@ -0,0 +1,58 @@
|
||||
use super::model::{
|
||||
InstallJobSnapshot, InstallJobState, InstallPhaseDetails, InstallPhaseId,
|
||||
InstallProgress,
|
||||
};
|
||||
use super::store;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InstallProgressReporter {
|
||||
job_id: Uuid,
|
||||
state: Arc<Mutex<InstallJobState>>,
|
||||
}
|
||||
|
||||
impl InstallProgressReporter {
|
||||
pub fn new(job_id: Uuid, state: InstallJobState) -> Self {
|
||||
Self {
|
||||
job_id,
|
||||
state: Arc::new(Mutex::new(state)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
phase: InstallPhaseId,
|
||||
progress: Option<InstallProgress>,
|
||||
details: InstallPhaseDetails,
|
||||
) -> crate::Result<()> {
|
||||
let app_state = crate::State::get().await?;
|
||||
let mut state = self.state.lock().await;
|
||||
state.progress.phase = phase;
|
||||
state.progress.progress = progress;
|
||||
state.progress.details = details;
|
||||
|
||||
let record =
|
||||
store::update_state(self.job_id, &state, &app_state).await?;
|
||||
emit_install_job(&record.snapshot()).await
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_install_job(
|
||||
snapshot: &InstallJobSnapshot,
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
use tauri::Emitter;
|
||||
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit("install_job", snapshot)
|
||||
.map_err(crate::event::EventError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
pub mod events;
|
||||
pub mod model;
|
||||
pub mod recovery;
|
||||
pub mod runner;
|
||||
pub mod store;
|
||||
|
||||
pub use events::InstallProgressReporter;
|
||||
pub use model::{
|
||||
InstallErrorView, InstallJavaStep, InstallJobKind, InstallJobSnapshot,
|
||||
InstallJobStatus, InstallModpackPreview, InstallPhaseDetails,
|
||||
InstallPhaseId, InstallPostInstallEdit, InstallProgress,
|
||||
InstallProgressSecondary, InstallRequest,
|
||||
};
|
||||
pub use runner::{
|
||||
cancel_job, create_instance, create_modpack_instance, dismiss_job,
|
||||
duplicate_instance, get_job, import_instance, install_existing_instance,
|
||||
install_pack_to_existing_instance, list_jobs, retry_job,
|
||||
};
|
||||
@@ -0,0 +1,360 @@
|
||||
use crate::api::pack::import::ImportLauncherType;
|
||||
use crate::api::pack::install_from::{CreatePackInstance, CreatePackLocation};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, InstanceLink, InstanceMetadata, ModLoader,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type InstallModpackPreview = CreatePackInstance;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallJobState {
|
||||
pub schema_version: u32,
|
||||
pub request: InstallRequest,
|
||||
pub target: InstallTarget,
|
||||
pub cleanup: InstallCleanup,
|
||||
pub progress: InstallProgressState,
|
||||
pub paths: InstallJobPaths,
|
||||
#[serde(default)]
|
||||
pub display: Option<InstallJobDisplay>,
|
||||
pub rollback: Option<InstallRollbackState>,
|
||||
pub error: Option<InstallErrorView>,
|
||||
}
|
||||
|
||||
impl InstallJobState {
|
||||
pub fn new(request: InstallRequest) -> Self {
|
||||
let target = request.target();
|
||||
let cleanup = request.cleanup();
|
||||
let phase = InstallPhaseId::PreparingInstance;
|
||||
|
||||
Self {
|
||||
schema_version: 1,
|
||||
request,
|
||||
target,
|
||||
cleanup,
|
||||
progress: InstallProgressState {
|
||||
phase,
|
||||
progress: None,
|
||||
details: InstallPhaseDetails::Empty,
|
||||
},
|
||||
paths: InstallJobPaths::default(),
|
||||
display: None,
|
||||
rollback: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InstallRequest {
|
||||
CreateInstance {
|
||||
name: String,
|
||||
game_version: String,
|
||||
loader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
link: InstanceLink,
|
||||
},
|
||||
CreateModpackInstance {
|
||||
location: CreatePackLocation,
|
||||
#[serde(default)]
|
||||
post_install_edit: Option<InstallPostInstallEdit>,
|
||||
},
|
||||
ImportInstance {
|
||||
launcher_type: ImportLauncherType,
|
||||
base_path: PathBuf,
|
||||
instance_folder: String,
|
||||
},
|
||||
DuplicateInstance {
|
||||
source_instance_id: String,
|
||||
},
|
||||
InstallExistingInstance {
|
||||
instance_id: String,
|
||||
force: bool,
|
||||
},
|
||||
InstallPackToExistingInstance {
|
||||
instance_id: String,
|
||||
location: CreatePackLocation,
|
||||
#[serde(default)]
|
||||
post_install_edit: Option<InstallPostInstallEdit>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct InstallPostInstallEdit {
|
||||
pub name: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub icon_path: Option<Option<String>>,
|
||||
pub link: Option<InstanceLink>,
|
||||
}
|
||||
|
||||
impl InstallRequest {
|
||||
pub fn kind(&self) -> InstallJobKind {
|
||||
match self {
|
||||
Self::CreateInstance { .. } => InstallJobKind::CreateInstance,
|
||||
Self::CreateModpackInstance { .. } => {
|
||||
InstallJobKind::CreateModpackInstance
|
||||
}
|
||||
Self::ImportInstance { .. } => InstallJobKind::ImportInstance,
|
||||
Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance,
|
||||
Self::InstallExistingInstance { .. } => {
|
||||
InstallJobKind::InstallExistingInstance
|
||||
}
|
||||
Self::InstallPackToExistingInstance { .. } => {
|
||||
InstallJobKind::InstallPackToExistingInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(&self) -> InstallTarget {
|
||||
match self {
|
||||
Self::InstallExistingInstance { instance_id, .. }
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. } => {
|
||||
InstallTarget::ExistingInstance {
|
||||
instance_id: instance_id.clone(),
|
||||
}
|
||||
}
|
||||
_ => InstallTarget::NewInstance { instance_id: None },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cleanup(&self) -> InstallCleanup {
|
||||
match self {
|
||||
Self::InstallExistingInstance { instance_id, .. }
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. } => {
|
||||
InstallCleanup::RestoreExistingInstance {
|
||||
instance_id: instance_id.clone(),
|
||||
}
|
||||
}
|
||||
_ => InstallCleanup::DeleteNewInstance { instance_id: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstallJobKind {
|
||||
CreateInstance,
|
||||
CreateModpackInstance,
|
||||
ImportInstance,
|
||||
DuplicateInstance,
|
||||
InstallExistingInstance,
|
||||
InstallPackToExistingInstance,
|
||||
}
|
||||
|
||||
impl InstallJobKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::CreateInstance => "create_instance",
|
||||
Self::CreateModpackInstance => "create_modpack_instance",
|
||||
Self::ImportInstance => "import_instance",
|
||||
Self::DuplicateInstance => "duplicate_instance",
|
||||
Self::InstallExistingInstance => "install_existing_instance",
|
||||
Self::InstallPackToExistingInstance => {
|
||||
"install_pack_to_existing_instance"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_stored_str(value: &str) -> Self {
|
||||
match value {
|
||||
"create_modpack_instance" => Self::CreateModpackInstance,
|
||||
"import_instance" => Self::ImportInstance,
|
||||
"duplicate_instance" => Self::DuplicateInstance,
|
||||
"install_existing_instance" => Self::InstallExistingInstance,
|
||||
"install_pack_to_existing_instance" => {
|
||||
Self::InstallPackToExistingInstance
|
||||
}
|
||||
_ => Self::CreateInstance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstallJobStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Interrupted,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
impl InstallJobStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Queued => "queued",
|
||||
Self::Running => "running",
|
||||
Self::Succeeded => "succeeded",
|
||||
Self::Failed => "failed",
|
||||
Self::Interrupted => "interrupted",
|
||||
Self::Canceled => "canceled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_stored_str(value: &str) -> Self {
|
||||
match value {
|
||||
"running" => Self::Running,
|
||||
"succeeded" => Self::Succeeded,
|
||||
"failed" => Self::Failed,
|
||||
"interrupted" => Self::Interrupted,
|
||||
"canceled" => Self::Canceled,
|
||||
_ => Self::Queued,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_finished(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Succeeded | Self::Failed | Self::Interrupted | Self::Canceled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InstallTarget {
|
||||
NewInstance { instance_id: Option<String> },
|
||||
ExistingInstance { instance_id: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InstallCleanup {
|
||||
DeleteNewInstance { instance_id: Option<String> },
|
||||
RestoreExistingInstance { instance_id: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallProgressState {
|
||||
pub phase: InstallPhaseId,
|
||||
pub progress: Option<InstallProgress>,
|
||||
pub details: InstallPhaseDetails,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstallPhaseId {
|
||||
PreparingInstance,
|
||||
ResolvingPack,
|
||||
DownloadingPackFile,
|
||||
ReadingPackManifest,
|
||||
DownloadingContent,
|
||||
ExtractingOverrides,
|
||||
ResolvingMinecraft,
|
||||
ResolvingLoader,
|
||||
PreparingJava,
|
||||
DownloadingMinecraft,
|
||||
RunningLoaderProcessors,
|
||||
Finalizing,
|
||||
RollingBack,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallProgress {
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub secondary: Option<InstallProgressSecondary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallProgressSecondary {
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstallJavaStep {
|
||||
Resolving,
|
||||
FetchingMetadata,
|
||||
Downloading,
|
||||
Extracting,
|
||||
Validating,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InstallPhaseDetails {
|
||||
Empty,
|
||||
Instance {
|
||||
name: String,
|
||||
},
|
||||
Minecraft {
|
||||
game_version: String,
|
||||
loader: ModLoader,
|
||||
},
|
||||
Java {
|
||||
major_version: u32,
|
||||
step: InstallJavaStep,
|
||||
},
|
||||
Modpack {
|
||||
project_id: Option<String>,
|
||||
version_id: Option<String>,
|
||||
title: Option<String>,
|
||||
},
|
||||
Import {
|
||||
launcher_type: ImportLauncherType,
|
||||
instance_folder: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct InstallJobPaths {
|
||||
pub staging_dir: Option<PathBuf>,
|
||||
pub final_instance_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallJobDisplay {
|
||||
pub title: String,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallRollbackState {
|
||||
pub instance: InstanceMetadata,
|
||||
pub install_stage: InstanceInstallStage,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallErrorView {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl InstallErrorView {
|
||||
pub fn from_error(code: &str, error: impl ToString) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallJobSnapshot {
|
||||
pub job_id: Uuid,
|
||||
pub instance_id: Option<String>,
|
||||
pub kind: InstallJobKind,
|
||||
pub status: InstallJobStatus,
|
||||
pub target: InstallTarget,
|
||||
pub phase: InstallPhaseId,
|
||||
pub progress: Option<InstallProgress>,
|
||||
pub details: InstallPhaseDetails,
|
||||
pub display: Option<InstallJobDisplay>,
|
||||
pub error: Option<InstallErrorView>,
|
||||
pub created: DateTime<Utc>,
|
||||
pub modified: DateTime<Utc>,
|
||||
pub finished: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::events::emit_install_job;
|
||||
use super::model::{
|
||||
InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobState,
|
||||
InstallJobStatus, InstallPhaseDetails, InstallPhaseId, InstallRequest,
|
||||
InstallTarget,
|
||||
};
|
||||
use super::store;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::State;
|
||||
|
||||
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
|
||||
let jobs = store::list_interrupted_candidates(state).await?;
|
||||
|
||||
for mut job in jobs {
|
||||
if job.state.display.is_none() {
|
||||
job.state.display = display_from_request(&job.state);
|
||||
}
|
||||
job.state.progress.phase = InstallPhaseId::RollingBack;
|
||||
job.state.progress.progress = None;
|
||||
job.state.progress.details = InstallPhaseDetails::Empty;
|
||||
job.state.error = Some(InstallErrorView {
|
||||
code: "interrupted".to_string(),
|
||||
message: "interrupted".to_string(),
|
||||
});
|
||||
|
||||
if let Err(error) = apply_cleanup(&job.state, state).await {
|
||||
tracing::error!(
|
||||
"Error cleaning up interrupted install job {}: {error}",
|
||||
job.id
|
||||
);
|
||||
}
|
||||
clear_deleted_new_instance_id(&mut job.state);
|
||||
|
||||
let record = store::update_status(
|
||||
job.id,
|
||||
InstallJobStatus::Interrupted,
|
||||
&job.state,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
||||
match &state.request {
|
||||
InstallRequest::CreateInstance { name, icon_path, .. } => {
|
||||
Some(InstallJobDisplay {
|
||||
title: name.clone(),
|
||||
icon: icon_path.clone(),
|
||||
})
|
||||
}
|
||||
InstallRequest::CreateModpackInstance { location, .. } => match location {
|
||||
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
|
||||
title,
|
||||
icon_url,
|
||||
..
|
||||
} => Some(InstallJobDisplay {
|
||||
title: title.clone(),
|
||||
icon: icon_url.clone(),
|
||||
}),
|
||||
crate::api::pack::install_from::CreatePackLocation::FromFile { .. } => None,
|
||||
},
|
||||
InstallRequest::ImportInstance {
|
||||
instance_folder, ..
|
||||
} => Some(InstallJobDisplay {
|
||||
title: instance_folder.clone(),
|
||||
icon: None,
|
||||
}),
|
||||
InstallRequest::DuplicateInstance { .. }
|
||||
| InstallRequest::InstallExistingInstance { .. }
|
||||
| InstallRequest::InstallPackToExistingInstance { .. } => {
|
||||
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
|
||||
title: rollback.instance.instance.name.clone(),
|
||||
icon: rollback.instance.instance.icon_path.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_cleanup(
|
||||
job_state: &InstallJobState,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
match &job_state.cleanup {
|
||||
InstallCleanup::DeleteNewInstance { instance_id } => {
|
||||
if let Some(instance_id) = instance_id {
|
||||
let _ = crate::state::remove_instance(instance_id, state).await;
|
||||
let _ =
|
||||
emit_instance(instance_id, InstancePayloadType::Removed)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
InstallCleanup::RestoreExistingInstance { instance_id } => {
|
||||
if let Some(rollback) = &job_state.rollback {
|
||||
crate::state::instances::commands::set_instance_install_stage(
|
||||
instance_id,
|
||||
rollback.install_stage,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
use super::events::{InstallProgressReporter, emit_install_job};
|
||||
use super::model::{
|
||||
InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobSnapshot,
|
||||
InstallJobState, InstallJobStatus, InstallPhaseDetails, InstallPhaseId,
|
||||
InstallPostInstallEdit, InstallRequest, InstallRollbackState,
|
||||
InstallTarget,
|
||||
};
|
||||
use super::{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::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<String>,
|
||||
icon_path: Option<String>,
|
||||
link: InstanceLink,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
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<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,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
start(InstallRequest::ImportInstance {
|
||||
launcher_type,
|
||||
base_path,
|
||||
instance_folder,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn duplicate_instance(
|
||||
source_instance_id: String,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
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 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 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 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.progress.phase = InstallPhaseId::PreparingInstance;
|
||||
job.state.progress.progress = None;
|
||||
job.state.progress.details = InstallPhaseDetails::Empty;
|
||||
prepare_initial_instance(&mut job.state, &state).await?;
|
||||
|
||||
let record = store::update_status(
|
||||
job_id,
|
||||
InstallJobStatus::Queued,
|
||||
&job.state,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
spawn_job(job_id);
|
||||
|
||||
Ok(record.snapshot())
|
||||
}
|
||||
|
||||
pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
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());
|
||||
}
|
||||
|
||||
job.state.error = Some(InstallErrorView {
|
||||
code: "canceled".to_string(),
|
||||
message: "Install was canceled".to_string(),
|
||||
});
|
||||
recovery::apply_cleanup(&job.state, &state).await?;
|
||||
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())
|
||||
}
|
||||
|
||||
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<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 =
|
||||
store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?;
|
||||
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 = 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::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, ..
|
||||
} => {
|
||||
prepare_existing_rollback(job_state, state, &instance_id).await?;
|
||||
}
|
||||
}
|
||||
|
||||
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 to update state: {error}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let job = store::get_required(job_id, &state).await?;
|
||||
|
||||
if job.status != InstallJobStatus::Queued {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut job_state = job.state.clone();
|
||||
let record = store::update_status(
|
||||
job_id,
|
||||
InstallJobStatus::Running,
|
||||
&job_state,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
|
||||
let result = run_request(job_id, &mut job_state, &state).await;
|
||||
|
||||
match result {
|
||||
Ok(instance_id) => {
|
||||
if let Some(instance_id) = instance_id {
|
||||
set_instance_id(&mut job_state, instance_id);
|
||||
}
|
||||
job_state.progress.phase = InstallPhaseId::Finalizing;
|
||||
job_state.progress.progress = None;
|
||||
job_state.progress.details = InstallPhaseDetails::Empty;
|
||||
job_state.error = None;
|
||||
let record = store::update_status(
|
||||
job_id,
|
||||
InstallJobStatus::Succeeded,
|
||||
&job_state,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
job_state.progress.phase = InstallPhaseId::RollingBack;
|
||||
job_state.progress.progress = None;
|
||||
job_state.progress.details = InstallPhaseDetails::Empty;
|
||||
job_state.error = Some(install_error_view(&error));
|
||||
recovery::apply_cleanup(&job_state, &state).await?;
|
||||
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 run_request(
|
||||
job_id: Uuid,
|
||||
job_state: &mut InstallJobState,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<String>> {
|
||||
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?;
|
||||
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::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?;
|
||||
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?;
|
||||
let disabled_project_ids = remove_existing_pack_content(
|
||||
job_id,
|
||||
job_state,
|
||||
state,
|
||||
&instance_id,
|
||||
)
|
||||
.await?;
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_existing_pack_content(
|
||||
job_id: Uuid,
|
||||
job_state: &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 { .. } => {
|
||||
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::<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?;
|
||||
|
||||
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 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::<std::collections::HashMap<_, _>>();
|
||||
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?;
|
||||
content_rows::remove_content_entries_for_file(
|
||||
&metadata.applied_content_set.id,
|
||||
&file.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
content_rows::remove_instance_file_by_relative_path(
|
||||
instance_id,
|
||||
&file.relative_path,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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?
|
||||
{
|
||||
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(())
|
||||
}
|
||||
|
||||
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,
|
||||
} => {
|
||||
generate_pack_from_version_id_with_reporter(
|
||||
project_id,
|
||||
version_id,
|
||||
title,
|
||||
icon_url,
|
||||
instance_id.clone(),
|
||||
reason,
|
||||
reporter.clone(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
CreatePackLocation::FromFile { path } => {
|
||||
generate_pack_from_file(path, instance_id.clone()).await?
|
||||
}
|
||||
};
|
||||
|
||||
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}"
|
||||
))
|
||||
})?;
|
||||
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(),
|
||||
};
|
||||
|
||||
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.progress.phase = phase;
|
||||
job_state.progress.progress = None;
|
||||
job_state.progress.details = 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),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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(error: &crate::Error) -> InstallErrorView {
|
||||
match error.raw.as_ref() {
|
||||
ErrorKind::FetchError(_)
|
||||
| ErrorKind::ApiIsDownError(_)
|
||||
| ErrorKind::WSError(_)
|
||||
| ErrorKind::WSClosedError(_) => InstallErrorView {
|
||||
code: "network_error".to_string(),
|
||||
message: "network_error".to_string(),
|
||||
},
|
||||
_ => InstallErrorView {
|
||||
code: "unknown_error".to_string(),
|
||||
message: "unknown_error".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use super::model::{
|
||||
InstallJobKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
|
||||
};
|
||||
use crate::state::State;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InstallJobRecord {
|
||||
pub id: Uuid,
|
||||
pub instance_id: Option<String>,
|
||||
pub kind: InstallJobKind,
|
||||
pub status: InstallJobStatus,
|
||||
pub state: InstallJobState,
|
||||
pub created: DateTime<Utc>,
|
||||
pub modified: DateTime<Utc>,
|
||||
pub finished: Option<DateTime<Utc>>,
|
||||
pub dismissed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InstallJobRow {
|
||||
pub id: String,
|
||||
pub instance_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub status: String,
|
||||
pub state: String,
|
||||
pub created: i64,
|
||||
pub modified: i64,
|
||||
pub finished: Option<i64>,
|
||||
pub dismissed: i64,
|
||||
}
|
||||
|
||||
impl InstallJobRecord {
|
||||
pub fn snapshot(&self) -> InstallJobSnapshot {
|
||||
InstallJobSnapshot {
|
||||
job_id: self.id,
|
||||
instance_id: self.instance_id.clone(),
|
||||
kind: self.kind,
|
||||
status: self.status,
|
||||
target: self.state.target.clone(),
|
||||
phase: self.state.progress.phase,
|
||||
progress: self.state.progress.progress.clone(),
|
||||
details: self.state.progress.details.clone(),
|
||||
display: self.state.display.clone(),
|
||||
error: self.state.error.clone(),
|
||||
created: self.created,
|
||||
modified: self.modified,
|
||||
finished: self.finished,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert(
|
||||
id: Uuid,
|
||||
state: &InstallJobState,
|
||||
status: InstallJobStatus,
|
||||
app_state: &State,
|
||||
) -> crate::Result<InstallJobRecord> {
|
||||
let now = Utc::now();
|
||||
let kind = state.request.kind();
|
||||
let json = serde_json::to_string(state)?;
|
||||
let status_value = status.as_str();
|
||||
let kind_value = kind.as_str();
|
||||
let instance_id = instance_id(state);
|
||||
let id_value = id.to_string();
|
||||
let created = now.timestamp();
|
||||
let modified = created;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO install_jobs (
|
||||
id, instance_id, kind, status, state, created, modified, finished, dismissed
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 0)
|
||||
",
|
||||
id_value,
|
||||
instance_id,
|
||||
kind_value,
|
||||
status_value,
|
||||
json,
|
||||
created,
|
||||
modified,
|
||||
)
|
||||
.execute(&app_state.pool)
|
||||
.await?;
|
||||
|
||||
get(id, app_state).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Install job {id} was not inserted"
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
id: Uuid,
|
||||
app_state: &State,
|
||||
) -> crate::Result<Option<InstallJobRecord>> {
|
||||
let id = id.to_string();
|
||||
let row = sqlx::query_as!(
|
||||
InstallJobRow,
|
||||
"
|
||||
SELECT
|
||||
id AS \"id!: String\",
|
||||
instance_id,
|
||||
kind AS \"kind!: String\",
|
||||
status AS \"status!: String\",
|
||||
state AS \"state!: String\",
|
||||
created AS \"created!: i64\",
|
||||
modified AS \"modified!: i64\",
|
||||
finished,
|
||||
dismissed AS \"dismissed!: i64\"
|
||||
FROM install_jobs
|
||||
WHERE id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&app_state.pool)
|
||||
.await?;
|
||||
|
||||
row.map(row_to_record).transpose()
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
include_finished: bool,
|
||||
app_state: &State,
|
||||
) -> crate::Result<Vec<InstallJobRecord>> {
|
||||
let rows = if include_finished {
|
||||
sqlx::query_as!(
|
||||
InstallJobRow,
|
||||
"
|
||||
SELECT
|
||||
id AS \"id!: String\",
|
||||
instance_id,
|
||||
kind AS \"kind!: String\",
|
||||
status AS \"status!: String\",
|
||||
state AS \"state!: String\",
|
||||
created AS \"created!: i64\",
|
||||
modified AS \"modified!: i64\",
|
||||
finished,
|
||||
dismissed AS \"dismissed!: i64\"
|
||||
FROM install_jobs
|
||||
WHERE dismissed = 0
|
||||
ORDER BY created ASC
|
||||
",
|
||||
)
|
||||
.fetch_all(&app_state.pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
InstallJobRow,
|
||||
"
|
||||
SELECT
|
||||
id AS \"id!: String\",
|
||||
instance_id,
|
||||
kind AS \"kind!: String\",
|
||||
status AS \"status!: String\",
|
||||
state AS \"state!: String\",
|
||||
created AS \"created!: i64\",
|
||||
modified AS \"modified!: i64\",
|
||||
finished,
|
||||
dismissed AS \"dismissed!: i64\"
|
||||
FROM install_jobs
|
||||
WHERE dismissed = 0 AND status IN ('queued', 'running', 'failed', 'interrupted')
|
||||
ORDER BY created ASC
|
||||
",
|
||||
)
|
||||
.fetch_all(&app_state.pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
rows.into_iter().map(row_to_record).collect()
|
||||
}
|
||||
|
||||
pub async fn list_interrupted_candidates(
|
||||
app_state: &State,
|
||||
) -> crate::Result<Vec<InstallJobRecord>> {
|
||||
let rows = sqlx::query_as!(
|
||||
InstallJobRow,
|
||||
"
|
||||
SELECT
|
||||
id AS \"id!: String\",
|
||||
instance_id,
|
||||
kind AS \"kind!: String\",
|
||||
status AS \"status!: String\",
|
||||
state AS \"state!: String\",
|
||||
created AS \"created!: i64\",
|
||||
modified AS \"modified!: i64\",
|
||||
finished,
|
||||
dismissed AS \"dismissed!: i64\"
|
||||
FROM install_jobs
|
||||
WHERE status IN ('queued', 'running')
|
||||
ORDER BY created ASC
|
||||
",
|
||||
)
|
||||
.fetch_all(&app_state.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(row_to_record).collect()
|
||||
}
|
||||
|
||||
pub async fn update_state(
|
||||
id: Uuid,
|
||||
state: &InstallJobState,
|
||||
app_state: &State,
|
||||
) -> crate::Result<InstallJobRecord> {
|
||||
let now = Utc::now();
|
||||
let json = serde_json::to_string(state)?;
|
||||
let instance_id = instance_id(state);
|
||||
let id_value = id.to_string();
|
||||
let modified = now.timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE install_jobs
|
||||
SET instance_id = ?, state = ?, modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
instance_id,
|
||||
json,
|
||||
modified,
|
||||
id_value,
|
||||
)
|
||||
.execute(&app_state.pool)
|
||||
.await?;
|
||||
|
||||
get_required(id, app_state).await
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
id: Uuid,
|
||||
status: InstallJobStatus,
|
||||
state: &InstallJobState,
|
||||
app_state: &State,
|
||||
) -> crate::Result<InstallJobRecord> {
|
||||
let now = Utc::now();
|
||||
let finished = status.is_finished().then_some(now.timestamp());
|
||||
let json = serde_json::to_string(state)?;
|
||||
let status_value = status.as_str();
|
||||
let instance_id = instance_id(state);
|
||||
let id_value = id.to_string();
|
||||
let modified = now.timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE install_jobs
|
||||
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
instance_id,
|
||||
status_value,
|
||||
json,
|
||||
modified,
|
||||
finished,
|
||||
id_value,
|
||||
)
|
||||
.execute(&app_state.pool)
|
||||
.await?;
|
||||
|
||||
get_required(id, app_state).await
|
||||
}
|
||||
|
||||
pub async fn dismiss(id: Uuid, app_state: &State) -> crate::Result<()> {
|
||||
let id = id.to_string();
|
||||
let modified = Utc::now().timestamp();
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE install_jobs
|
||||
SET dismissed = 1, modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
modified,
|
||||
id,
|
||||
)
|
||||
.execute(&app_state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_required(
|
||||
id: Uuid,
|
||||
app_state: &State,
|
||||
) -> crate::Result<InstallJobRecord> {
|
||||
get(id, app_state).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!("Unknown install job {id}")).into()
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_record(row: InstallJobRow) -> crate::Result<InstallJobRecord> {
|
||||
Ok(InstallJobRecord {
|
||||
id: Uuid::parse_str(&row.id).map_err(|err| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid install job id {}: {err}",
|
||||
row.id
|
||||
))
|
||||
})?,
|
||||
instance_id: row.instance_id,
|
||||
kind: InstallJobKind::from_stored_str(&row.kind),
|
||||
status: InstallJobStatus::from_stored_str(&row.status),
|
||||
state: serde_json::from_str(&row.state)?,
|
||||
created: timestamp(row.created),
|
||||
modified: timestamp(row.modified),
|
||||
finished: row.finished.and_then(optional_timestamp),
|
||||
dismissed: row.dismissed != 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn instance_id(state: &InstallJobState) -> Option<String> {
|
||||
match &state.target {
|
||||
super::model::InstallTarget::NewInstance { instance_id } => {
|
||||
instance_id.clone()
|
||||
}
|
||||
super::model::InstallTarget::ExistingInstance { instance_id } => {
|
||||
Some(instance_id.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp(value: i64) -> DateTime<Utc> {
|
||||
Utc.timestamp_opt(value, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now)
|
||||
}
|
||||
|
||||
fn optional_timestamp(value: i64) -> Option<DateTime<Utc>> {
|
||||
Utc.timestamp_opt(value, 0).single()
|
||||
}
|
||||
Reference in New Issue
Block a user