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:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+7 -4
View File
@@ -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,
};
+67 -2
View File
@@ -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,
}
+284 -8
View File
@@ -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?;
}
}
+262 -16
View File
@@ -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