mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 17:44:50 +00:00
feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,7 @@ pub mod events;
|
||||
pub mod model;
|
||||
pub mod recovery;
|
||||
pub mod runner;
|
||||
mod shared_instance;
|
||||
pub mod store;
|
||||
|
||||
pub use events::InstallProgressReporter;
|
||||
@@ -11,11 +12,13 @@ pub use model::{
|
||||
InstallJobEventKind, InstallJobKind, InstallJobSnapshot, InstallJobStatus,
|
||||
InstallModpackPreview, InstallPhaseDetails, InstallPhaseId,
|
||||
InstallPostInstallEdit, InstallProgress, InstallProgressSecondary,
|
||||
InstallRequest,
|
||||
InstallRequest, SharedInstanceExternalFileData, SharedInstanceInstallData,
|
||||
SharedInstanceInstallModpack,
|
||||
};
|
||||
pub use runner::{
|
||||
cancel_job, create_instance, create_modpack_instance, dismiss_job,
|
||||
duplicate_instance, get_job, import_instance, install_existing_instance,
|
||||
cancel_job, create_instance, create_modpack_instance,
|
||||
create_shared_instance, dismiss_job, duplicate_instance, get_job,
|
||||
import_instance, install_existing_instance,
|
||||
install_pack_to_existing_instance, job_support_details, list_jobs,
|
||||
retry_job,
|
||||
retry_job, update_shared_instance,
|
||||
};
|
||||
|
||||
@@ -169,6 +169,9 @@ pub enum InstallRequest {
|
||||
#[serde(default)]
|
||||
post_install_edit: Option<InstallPostInstallEdit>,
|
||||
},
|
||||
CreateSharedInstance {
|
||||
data: SharedInstanceInstallData,
|
||||
},
|
||||
ImportInstance {
|
||||
launcher_type: ImportLauncherType,
|
||||
base_path: PathBuf,
|
||||
@@ -187,6 +190,10 @@ pub enum InstallRequest {
|
||||
#[serde(default)]
|
||||
post_install_edit: Option<InstallPostInstallEdit>,
|
||||
},
|
||||
UpdateSharedInstance {
|
||||
instance_id: String,
|
||||
data: SharedInstanceInstallData,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
@@ -201,6 +208,46 @@ pub struct InstallPostInstallEdit {
|
||||
pub link: Option<InstanceLink>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SharedInstanceInstallData {
|
||||
pub shared_instance_id: String,
|
||||
pub manager_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_manager_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_manager_icon_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub instance_icon_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub linked_user_id: Option<String>,
|
||||
pub name: String,
|
||||
pub version: i32,
|
||||
pub modrinth_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub external_files: Vec<SharedInstanceExternalFileData>,
|
||||
pub modpack: Option<SharedInstanceInstallModpack>,
|
||||
pub game_version: String,
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SharedInstanceExternalFileData {
|
||||
pub file_name: String,
|
||||
pub file_type: String,
|
||||
pub url: String,
|
||||
pub file_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SharedInstanceInstallModpack {
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
pub title: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub dependency_count: usize,
|
||||
}
|
||||
|
||||
impl InstallRequest {
|
||||
pub fn kind(&self) -> InstallJobKind {
|
||||
match self {
|
||||
@@ -208,6 +255,9 @@ impl InstallRequest {
|
||||
Self::CreateModpackInstance { .. } => {
|
||||
InstallJobKind::CreateModpackInstance
|
||||
}
|
||||
Self::CreateSharedInstance { .. } => {
|
||||
InstallJobKind::CreateSharedInstance
|
||||
}
|
||||
Self::ImportInstance { .. } => InstallJobKind::ImportInstance,
|
||||
Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance,
|
||||
Self::InstallExistingInstance { .. } => {
|
||||
@@ -216,13 +266,17 @@ impl InstallRequest {
|
||||
Self::InstallPackToExistingInstance { .. } => {
|
||||
InstallJobKind::InstallPackToExistingInstance
|
||||
}
|
||||
Self::UpdateSharedInstance { .. } => {
|
||||
InstallJobKind::UpdateSharedInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(&self) -> InstallTarget {
|
||||
match self {
|
||||
Self::InstallExistingInstance { instance_id, .. }
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. } => {
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. }
|
||||
| Self::UpdateSharedInstance { instance_id, .. } => {
|
||||
InstallTarget::ExistingInstance {
|
||||
instance_id: instance_id.clone(),
|
||||
}
|
||||
@@ -234,7 +288,8 @@ impl InstallRequest {
|
||||
pub fn cleanup(&self) -> InstallCleanup {
|
||||
match self {
|
||||
Self::InstallExistingInstance { instance_id, .. }
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. } => {
|
||||
| Self::InstallPackToExistingInstance { instance_id, .. }
|
||||
| Self::UpdateSharedInstance { instance_id, .. } => {
|
||||
InstallCleanup::RestoreExistingInstance {
|
||||
instance_id: instance_id.clone(),
|
||||
}
|
||||
@@ -249,10 +304,12 @@ impl InstallRequest {
|
||||
pub enum InstallJobKind {
|
||||
CreateInstance,
|
||||
CreateModpackInstance,
|
||||
CreateSharedInstance,
|
||||
ImportInstance,
|
||||
DuplicateInstance,
|
||||
InstallExistingInstance,
|
||||
InstallPackToExistingInstance,
|
||||
UpdateSharedInstance,
|
||||
}
|
||||
|
||||
impl InstallJobKind {
|
||||
@@ -260,24 +317,28 @@ impl InstallJobKind {
|
||||
match self {
|
||||
Self::CreateInstance => "create_instance",
|
||||
Self::CreateModpackInstance => "create_modpack_instance",
|
||||
Self::CreateSharedInstance => "create_shared_instance",
|
||||
Self::ImportInstance => "import_instance",
|
||||
Self::DuplicateInstance => "duplicate_instance",
|
||||
Self::InstallExistingInstance => "install_existing_instance",
|
||||
Self::InstallPackToExistingInstance => {
|
||||
"install_pack_to_existing_instance"
|
||||
}
|
||||
Self::UpdateSharedInstance => "update_shared_instance",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_stored_str(value: &str) -> Self {
|
||||
match value {
|
||||
"create_modpack_instance" => Self::CreateModpackInstance,
|
||||
"create_shared_instance" => Self::CreateSharedInstance,
|
||||
"import_instance" => Self::ImportInstance,
|
||||
"duplicate_instance" => Self::DuplicateInstance,
|
||||
"install_existing_instance" => Self::InstallExistingInstance,
|
||||
"install_pack_to_existing_instance" => {
|
||||
Self::InstallPackToExistingInstance
|
||||
}
|
||||
"update_shared_instance" => Self::UpdateSharedInstance,
|
||||
_ => Self::CreateInstance,
|
||||
}
|
||||
}
|
||||
@@ -484,6 +545,8 @@ pub struct InstallErrorView {
|
||||
pub phase: Option<InstallPhaseId>,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<crate::SharedInstanceUnavailableReason>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<InstallApiErrorDetails>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<InstallErrorContext>,
|
||||
@@ -513,6 +576,7 @@ impl InstallErrorView {
|
||||
code: code.to_string(),
|
||||
phase: Some(phase),
|
||||
message: error.to_string(),
|
||||
reason: None,
|
||||
api: match error.raw.as_ref() {
|
||||
crate::ErrorKind::LabrinthError(error) => {
|
||||
Some(InstallApiErrorDetails {
|
||||
@@ -538,6 +602,7 @@ impl InstallErrorView {
|
||||
code: code.to_string(),
|
||||
phase: Some(phase),
|
||||
message: message.into(),
|
||||
reason: None,
|
||||
api: None,
|
||||
context: None,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,257 @@ use super::model::{
|
||||
use super::store;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::State;
|
||||
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
|
||||
use crate::state::{
|
||||
ContentEntry, ContentSetRemoteRef, ContentSetRemoteRefType,
|
||||
ContentSetSyncProvider, ContentSetSyncState, InstanceFile,
|
||||
InstanceMetadata, State,
|
||||
};
|
||||
use async_walkdir::WalkDir;
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
const SHARED_INSTANCE_ROLLBACK_FILE: &str = "rollback.json";
|
||||
const SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR: &str = "instance";
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct SharedInstanceUpdateRollback {
|
||||
files: Vec<InstanceFile>,
|
||||
entries: Vec<ContentEntry>,
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_shared_instance_update_backup(
|
||||
job_id: Uuid,
|
||||
metadata: &InstanceMetadata,
|
||||
state: &State,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let staging_dir = state
|
||||
.directories
|
||||
.metadata_dir()
|
||||
.join("install_job_backups")
|
||||
.join(job_id.to_string());
|
||||
if tokio::fs::try_exists(&staging_dir).await? {
|
||||
crate::util::io::remove_dir_all(&staging_dir).await?;
|
||||
}
|
||||
crate::util::io::create_dir_all(&staging_dir).await?;
|
||||
|
||||
let result = async {
|
||||
let files = content_rows::get_instance_files(
|
||||
&metadata.instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let entries = content_rows::get_content_entries(
|
||||
&metadata.applied_content_set.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let snapshot = SharedInstanceUpdateRollback { files, entries };
|
||||
let instance_path = state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&metadata.instance.path);
|
||||
copy_directory(
|
||||
&instance_path,
|
||||
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
crate::util::io::write(
|
||||
staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE),
|
||||
serde_json::to_vec(&snapshot)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = crate::util::io::remove_dir_all(&staging_dir).await;
|
||||
}
|
||||
result?;
|
||||
Ok(staging_dir)
|
||||
}
|
||||
|
||||
pub(super) async fn clear_staging_dir(job_state: &InstallJobState) {
|
||||
let Some(staging_dir) = &job_state.paths.staging_dir else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = crate::util::io::remove_dir_all(staging_dir).await
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(
|
||||
path = %staging_dir.display(),
|
||||
"Failed to remove install rollback backup: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_shared_instance_update(
|
||||
staging_dir: &Path,
|
||||
rollback: &super::model::InstallRollbackState,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let snapshot = serde_json::from_slice::<SharedInstanceUpdateRollback>(
|
||||
&crate::util::io::read(staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE))
|
||||
.await?,
|
||||
)?;
|
||||
let instance_path = state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&rollback.instance.instance.path);
|
||||
if tokio::fs::try_exists(&instance_path).await? {
|
||||
crate::util::io::remove_dir_all(&instance_path).await?;
|
||||
}
|
||||
copy_directory(
|
||||
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
|
||||
&instance_path,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
content_rows::restore_instance_content_snapshot(
|
||||
&rollback.instance.instance.id,
|
||||
&snapshot.files,
|
||||
&snapshot.entries,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
restore_instance_metadata(&rollback.instance, state).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_instance_metadata(
|
||||
metadata: &InstanceMetadata,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let content_set_id = metadata.applied_content_set.id.as_str();
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::update_instance(&metadata.instance, &mut tx).await?;
|
||||
content_rows::update_content_set(&metadata.applied_content_set, &mut tx)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_link(
|
||||
&metadata.instance.id,
|
||||
&metadata.link,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::set_shared_instance_attachment(
|
||||
&metadata.instance.id,
|
||||
metadata.shared_instance.as_ref(),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::replace_instance_groups(
|
||||
&metadata.instance.id,
|
||||
&metadata.groups,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_launch_overrides(
|
||||
&metadata.launch_overrides,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::delete_content_set_remote_ref(
|
||||
content_set_id,
|
||||
ContentSetRemoteRefType::SharedContentSet,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::delete_content_set_sync_state(content_set_id, &mut tx)
|
||||
.await?;
|
||||
if let Some(attachment) = &metadata.shared_instance {
|
||||
content_rows::upsert_content_set_remote_ref(
|
||||
&ContentSetRemoteRef {
|
||||
content_set_id: content_set_id.to_string(),
|
||||
ref_type: ContentSetRemoteRefType::SharedContentSet,
|
||||
ref_id: attachment.id.clone(),
|
||||
},
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::upsert_content_set_sync_state(
|
||||
&ContentSetSyncState {
|
||||
content_set_id: content_set_id.to_string(),
|
||||
provider: ContentSetSyncProvider::SharedInstance,
|
||||
applied_update_id: attachment
|
||||
.applied_version
|
||||
.map(|value| value.to_string()),
|
||||
latest_available_update_id: attachment
|
||||
.latest_version
|
||||
.map(|value| value.to_string()),
|
||||
checked_at: Some(Utc::now()),
|
||||
status: attachment.status,
|
||||
},
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_directory(
|
||||
source: &Path,
|
||||
target: &Path,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
crate::util::io::create_dir_all(target).await?;
|
||||
let mut walker = WalkDir::new(source);
|
||||
while let Some(entry) = walker.next().await {
|
||||
let entry = entry.map_err(|error| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"Failed to read instance backup path: {error}"
|
||||
))
|
||||
})?;
|
||||
let entry_path = entry.path();
|
||||
let relative_path = entry_path.strip_prefix(source)?;
|
||||
let target_path = target.join(relative_path);
|
||||
let file_type = entry.file_type().await?;
|
||||
if file_type.is_dir() {
|
||||
crate::util::io::create_dir_all(&target_path).await?;
|
||||
} else if file_type.is_file() {
|
||||
crate::util::fetch::copy(
|
||||
&entry_path,
|
||||
&target_path,
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
} else if file_type.is_symlink() {
|
||||
copy_symlink(&entry_path, &target_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
|
||||
if let Some(parent) = target.parent() {
|
||||
crate::util::io::create_dir_all(parent).await?;
|
||||
}
|
||||
let link_target = tokio::fs::read_link(source).await?;
|
||||
|
||||
#[cfg(unix)]
|
||||
tokio::fs::symlink(link_target, target).await?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let metadata = tokio::fs::metadata(source).await?;
|
||||
if metadata.is_dir() {
|
||||
tokio::fs::symlink_dir(link_target, target).await?;
|
||||
} else {
|
||||
tokio::fs::symlink_file(link_target, target).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
|
||||
let jobs = store::list_interrupted_candidates(state).await?;
|
||||
@@ -61,6 +311,9 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
if job.state.rollback_error.is_none() {
|
||||
clear_staging_dir(&job.state).await;
|
||||
}
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
}
|
||||
|
||||
@@ -96,6 +349,15 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
||||
..
|
||||
} => None,
|
||||
},
|
||||
InstallRequest::CreateSharedInstance { data } => {
|
||||
Some(InstallJobDisplay {
|
||||
title: data.name.clone(),
|
||||
icon: data
|
||||
.modpack
|
||||
.as_ref()
|
||||
.and_then(|modpack| modpack.icon_url.clone()),
|
||||
})
|
||||
}
|
||||
InstallRequest::ImportInstance {
|
||||
instance_folder, ..
|
||||
} => Some(InstallJobDisplay {
|
||||
@@ -104,7 +366,8 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
|
||||
}),
|
||||
InstallRequest::DuplicateInstance { .. }
|
||||
| InstallRequest::InstallExistingInstance { .. }
|
||||
| InstallRequest::InstallPackToExistingInstance { .. } => {
|
||||
| InstallRequest::InstallPackToExistingInstance { .. }
|
||||
| InstallRequest::UpdateSharedInstance { .. } => {
|
||||
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
|
||||
title: rollback.instance.instance.name.clone(),
|
||||
icon: rollback.instance.instance.icon_path.clone(),
|
||||
@@ -128,12 +391,25 @@ pub async fn apply_cleanup(
|
||||
}
|
||||
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?;
|
||||
if matches!(
|
||||
&job_state.request,
|
||||
InstallRequest::UpdateSharedInstance { .. }
|
||||
) && let Some(staging_dir) = &job_state.paths.staging_dir
|
||||
{
|
||||
restore_shared_instance_update(
|
||||
staging_dir,
|
||||
rollback,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
crate::state::instances::commands::set_instance_install_stage(
|
||||
instance_id,
|
||||
rollback.install_stage,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ use super::model::{
|
||||
InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay,
|
||||
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
|
||||
InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit,
|
||||
InstallRequest, InstallRollbackState, InstallTarget,
|
||||
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;
|
||||
@@ -53,6 +59,19 @@ pub async fn create_modpack_instance(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_shared_instance(
|
||||
data: SharedInstanceInstallData,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
start(InstallRequest::CreateSharedInstance { data }).await
|
||||
}
|
||||
|
||||
pub async fn update_shared_instance(
|
||||
instance_id: String,
|
||||
data: SharedInstanceInstallData,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
start(InstallRequest::UpdateSharedInstance { instance_id, data }).await
|
||||
}
|
||||
|
||||
pub async fn import_instance(
|
||||
launcher_type: crate::api::pack::import::ImportLauncherType,
|
||||
base_path: PathBuf,
|
||||
@@ -129,9 +148,15 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
.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;
|
||||
@@ -150,6 +175,7 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
lock_existing_instance_if_needed(&job.state, &state).await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
spawn_job(job_id);
|
||||
|
||||
@@ -204,6 +230,9 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
if job.state.rollback_error.is_none() {
|
||||
recovery::clear_staging_dir(&job.state).await;
|
||||
}
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
|
||||
Ok(record.snapshot())
|
||||
@@ -221,6 +250,7 @@ async fn start(request: InstallRequest) -> crate::Result<InstallJobSnapshot> {
|
||||
prepare_initial_instance(&mut job_state, &state).await?;
|
||||
let record =
|
||||
store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?;
|
||||
lock_existing_instance_if_needed(&job_state, &state).await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
spawn_job(id);
|
||||
Ok(record.snapshot())
|
||||
@@ -296,6 +326,54 @@ async fn prepare_initial_instance(
|
||||
);
|
||||
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;
|
||||
attach_pending_shared_instance(&instance_id, &data, state).await?;
|
||||
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
|
||||
set_instance_id(job_state, instance_id);
|
||||
}
|
||||
InstallRequest::ImportInstance {
|
||||
instance_folder, ..
|
||||
} => {
|
||||
@@ -343,7 +421,8 @@ async fn prepare_initial_instance(
|
||||
InstallRequest::InstallExistingInstance { instance_id, .. }
|
||||
| InstallRequest::InstallPackToExistingInstance {
|
||||
instance_id, ..
|
||||
} => {
|
||||
}
|
||||
| InstallRequest::UpdateSharedInstance { instance_id, .. } => {
|
||||
prepare_existing_rollback(job_state, state, &instance_id).await?;
|
||||
}
|
||||
}
|
||||
@@ -353,7 +432,7 @@ async fn prepare_initial_instance(
|
||||
|
||||
fn spawn_job(job_id: Uuid) {
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = run_job(job_id).await {
|
||||
if let Err(error) = Box::pin(run_job(job_id)).await {
|
||||
tracing::error!(
|
||||
"Install job {job_id} failed to update state: {error}"
|
||||
);
|
||||
@@ -387,16 +466,23 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||
.await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
|
||||
let result = run_request(job_id, &mut job_state, &state).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 {
|
||||
job_state = record.state;
|
||||
}
|
||||
|
||||
match result {
|
||||
let result = match result {
|
||||
Ok(instance_id) => {
|
||||
if let Some(instance_id) = instance_id {
|
||||
set_instance_id(&mut job_state, instance_id);
|
||||
}
|
||||
finalize_existing_instance_success(&job_state, &state).await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
job_state.record_event(InstallJobEventKind::JobSucceeded {
|
||||
instance_id: current_instance_id(&job_state),
|
||||
});
|
||||
@@ -413,6 +499,7 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
recovery::clear_staging_dir(&job_state).await;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -459,6 +546,9 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
if job_state.rollback_error.is_none() {
|
||||
recovery::clear_staging_dir(&job_state).await;
|
||||
}
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
return Err(error);
|
||||
}
|
||||
@@ -541,17 +631,39 @@ async fn run_request(
|
||||
modpack_details(&location),
|
||||
)
|
||||
.await?;
|
||||
install_pack(
|
||||
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,
|
||||
@@ -629,6 +741,7 @@ async fn run_request(
|
||||
}
|
||||
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,
|
||||
@@ -660,6 +773,7 @@ async fn run_request(
|
||||
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,
|
||||
@@ -667,13 +781,13 @@ async fn run_request(
|
||||
&instance_id,
|
||||
)
|
||||
.await?;
|
||||
install_pack(
|
||||
Box::pin(install_pack(
|
||||
job_id,
|
||||
job_state,
|
||||
location,
|
||||
instance_id.clone(),
|
||||
DownloadReason::Modpack,
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
restore_disabled_projects(
|
||||
&instance_id,
|
||||
@@ -684,6 +798,49 @@ async fn run_request(
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,6 +871,20 @@ async fn apply_post_install_edit(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disabled_project_ids(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<HashSet<String>> {
|
||||
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,
|
||||
@@ -873,7 +1044,7 @@ async fn restore_disabled_projects(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_pack(
|
||||
pub(super) async fn install_pack(
|
||||
job_id: Uuid,
|
||||
job_state: &mut InstallJobState,
|
||||
location: CreatePackLocation,
|
||||
@@ -927,12 +1098,12 @@ async fn install_pack(
|
||||
}
|
||||
};
|
||||
|
||||
install_zipped_mrpack_files_with_reporter(
|
||||
Box::pin(install_zipped_mrpack_files_with_reporter(
|
||||
create_pack,
|
||||
false,
|
||||
reason,
|
||||
reporter,
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -954,6 +1125,12 @@ async fn prepare_existing_rollback(
|
||||
"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,
|
||||
@@ -968,6 +1145,26 @@ async fn prepare_existing_rollback(
|
||||
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,
|
||||
@@ -979,7 +1176,26 @@ async fn prepare_existing_rollback(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_progress(
|
||||
async fn finalize_existing_instance_success(
|
||||
job_state: &InstallJobState,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
if let InstallCleanup::RestoreExistingInstance { instance_id } =
|
||||
&job_state.cleanup
|
||||
{
|
||||
crate::state::instances::commands::set_instance_install_stage(
|
||||
instance_id,
|
||||
InstanceInstallStage::Installed,
|
||||
&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,
|
||||
@@ -992,6 +1208,25 @@ async fn update_progress(
|
||||
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 { .. } => {
|
||||
@@ -1036,12 +1271,16 @@ fn install_error_view(
|
||||
error: &crate::Error,
|
||||
context: Option<InstallErrorContext>,
|
||||
) -> InstallErrorView {
|
||||
InstallErrorView::from_error(
|
||||
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(
|
||||
@@ -1051,6 +1290,9 @@ fn install_error_code(
|
||||
use InstallPhaseId::*;
|
||||
|
||||
match error.raw.as_ref() {
|
||||
ErrorKind::SharedInstanceUnavailable(_) => {
|
||||
"shared_instance_unavailable"
|
||||
}
|
||||
ErrorKind::InputError(_) => match phase {
|
||||
PreparingInstance | Finalizing => "instance_error",
|
||||
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
|
||||
@@ -1079,6 +1321,8 @@ fn install_error_code(
|
||||
},
|
||||
ErrorKind::FetchError(_)
|
||||
| ErrorKind::ApiIsDownError(_)
|
||||
| ErrorKind::WSError(_)
|
||||
| ErrorKind::WSClosedError(_)
|
||||
| ErrorKind::Ratelimited { .. } => "network_error",
|
||||
ErrorKind::Any(_)
|
||||
if matches!(
|
||||
@@ -1123,7 +1367,9 @@ fn current_instance_id(job_state: &InstallJobState) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn modpack_details(location: &CreatePackLocation) -> InstallPhaseDetails {
|
||||
pub(super) fn modpack_details(
|
||||
location: &CreatePackLocation,
|
||||
) -> InstallPhaseDetails {
|
||||
match location {
|
||||
CreatePackLocation::FromVersionId {
|
||||
project_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user