mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
feat: install flow improvements (#6669)
* feat: better error handling + copy details btn for info * refactor: clean up error handling into diagnostics * feat: extra info for failure states + queuing properly * fix: cleanup * fix: lint * fix: fmt * fix: cleanup * fix: cleanup * fix: lint * feat: use bon builder
This commit is contained in:
@@ -34,6 +34,7 @@ pub async fn get_optimal_jre_key(
|
||||
loader_version.as_ref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Authentication flow interface
|
||||
use crate::event::emit::{emit_loading, init_loading};
|
||||
use crate::install::{
|
||||
InstallJavaStep, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||
InstallProgressReporter,
|
||||
InstallErrorContext, InstallJavaStep, InstallPhaseDetails, InstallPhaseId,
|
||||
InstallProgress, InstallProgressReporter,
|
||||
};
|
||||
use crate::state::JavaVersion;
|
||||
use crate::util::fetch::{
|
||||
@@ -148,23 +148,52 @@ async fn auto_install_java_inner(
|
||||
Some(java_step_progress(1)),
|
||||
)
|
||||
.await?;
|
||||
let metadata_url = format!(
|
||||
"https://api.azul.com/metadata/v1/zulu/packages?arch={}&java_version={}&os={}&archive_type=zip&javafx_bundled=false&java_package_type=jre&page_size=1",
|
||||
std::env::consts::ARCH,
|
||||
java_version,
|
||||
std::env::consts::OS
|
||||
);
|
||||
if let Some(reporter) = &reporter {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("fetch Java package metadata")
|
||||
.urls(vec![metadata_url.clone()])
|
||||
.java_version(java_version)
|
||||
.os(std::env::consts::OS)
|
||||
.arch(std::env::consts::ARCH)
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let packages = fetch_json::<Vec<Package>>(
|
||||
Method::GET,
|
||||
&format!(
|
||||
"https://api.azul.com/metadata/v1/zulu/packages?arch={}&java_version={}&os={}&archive_type=zip&javafx_bundled=false&java_package_type=jre&page_size=1",
|
||||
std::env::consts::ARCH, java_version, std::env::consts::OS
|
||||
),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
).await?;
|
||||
Method::GET,
|
||||
&metadata_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
if let Some(loading_bar) = &loading_bar {
|
||||
emit_loading(loading_bar, 10.0, Some("Downloading java version"))?;
|
||||
}
|
||||
|
||||
if let Some(download) = packages.first() {
|
||||
if let Some(reporter) = &reporter {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("download Java archive")
|
||||
.urls(vec![download.download_url.clone()])
|
||||
.file_path(download.name.display().to_string())
|
||||
.java_version(java_version)
|
||||
.os(std::env::consts::OS)
|
||||
.arch(std::env::consts::ARCH)
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
update_java_install_progress(
|
||||
reporter.as_ref(),
|
||||
java_version,
|
||||
@@ -237,6 +266,20 @@ async fn auto_install_java_inner(
|
||||
|
||||
let path = state.directories.java_versions_dir();
|
||||
|
||||
if let Some(reporter) = &reporter {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("read Java archive")
|
||||
.urls(vec![download.download_url.clone()])
|
||||
.file_path(download.name.display().to_string())
|
||||
.target_path(path.display().to_string())
|
||||
.java_version(java_version)
|
||||
.os(std::env::consts::OS)
|
||||
.arch(std::env::consts::ARCH)
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(file))
|
||||
.map_err(|_| {
|
||||
crate::Error::from(crate::ErrorKind::InputError(
|
||||
@@ -265,6 +308,20 @@ async fn auto_install_java_inner(
|
||||
Some(java_step_progress(3)),
|
||||
)
|
||||
.await?;
|
||||
if let Some(reporter) = &reporter {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("extract Java archive")
|
||||
.urls(vec![download.download_url.clone()])
|
||||
.file_path(download.name.display().to_string())
|
||||
.target_path(path.display().to_string())
|
||||
.java_version(java_version)
|
||||
.os(std::env::consts::OS)
|
||||
.arch(std::env::consts::ARCH)
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
archive.extract(&path).map_err(|_| {
|
||||
crate::Error::from(crate::ErrorKind::InputError(
|
||||
"Failed to extract java zip".to_string(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::State;
|
||||
use crate::data::ModLoader;
|
||||
use crate::install::{
|
||||
InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||
InstallErrorContext, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||
InstallProgressReporter,
|
||||
};
|
||||
use crate::state::{
|
||||
@@ -343,6 +343,13 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
|
||||
};
|
||||
let progress = Some(&mut progress as &mut FetchProgressFn<'_>);
|
||||
|
||||
let context = InstallErrorContext::new("download modpack file")
|
||||
.urls(vec![url.clone()])
|
||||
.maybe_expected_hash(hash.cloned())
|
||||
.project_id(project_id.clone())
|
||||
.version_id(version_id.clone())
|
||||
.build();
|
||||
reporter.set_context(context).await?;
|
||||
let file = fetch_advanced_with_progress(
|
||||
Method::GET,
|
||||
&url,
|
||||
@@ -358,6 +365,10 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
|
||||
)
|
||||
.await?;
|
||||
|
||||
reporter
|
||||
.update(InstallPhaseId::ResolvingPack, None, details.clone())
|
||||
.await?;
|
||||
|
||||
let project = CachedEntry::get_project(
|
||||
&version.project_id,
|
||||
None,
|
||||
@@ -377,6 +388,15 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
|
||||
let icon = if has_icon_url {
|
||||
if let Some(icon_url) = project.icon_url {
|
||||
let state = State::get().await?;
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("download modpack icon")
|
||||
.urls(vec![icon_url.clone()])
|
||||
.project_id(project_id.clone())
|
||||
.version_id(version_id.clone())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
let icon_bytes = fetch(
|
||||
&icon_url,
|
||||
None,
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
use crate::State;
|
||||
use crate::event::emit::loading_try_for_each_concurrent;
|
||||
use crate::install::{
|
||||
InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||
InstallProgressReporter, InstallProgressSecondary,
|
||||
InstallErrorContext, InstallJobEventKind, InstallPhaseDetails,
|
||||
InstallPhaseId, InstallProgress, InstallProgressReporter,
|
||||
InstallProgressSecondary,
|
||||
};
|
||||
use crate::pack::install_from::{
|
||||
EnvType, PackFile, PackFileHash, set_instance_information,
|
||||
};
|
||||
use crate::state::instances::ContentSourceKind;
|
||||
use crate::state::{
|
||||
CachedEntry, EditInstance, InstanceInstallStage, SideType, cache_file_hash,
|
||||
CachedEntry, CachedFile, EditInstance, InstanceInstallStage, SideType,
|
||||
cache_file_hash,
|
||||
};
|
||||
use crate::util::fetch::{
|
||||
DownloadMeta, DownloadReason, FetchProgressFn, fetch_mirrors_with_progress,
|
||||
write,
|
||||
};
|
||||
use crate::util::fetch::{DownloadMeta, DownloadReason, fetch_mirrors, write};
|
||||
use crate::util::io;
|
||||
use async_zip::base::read::seek::ZipFileReader as SeekZipFileReader;
|
||||
use async_zip::base::read::{WithEntry, ZipEntryReader};
|
||||
use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader;
|
||||
use futures::StreamExt;
|
||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{
|
||||
@@ -28,12 +34,80 @@ use std::sync::{
|
||||
use super::install_from::{CreatePack, CreatePackFile, PackFormat};
|
||||
use crate::data::ProjectType;
|
||||
use std::io::{Cursor, ErrorKind};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
type ExtractProgressFn<'a> = dyn FnMut(u64) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + 'a>>
|
||||
+ Send
|
||||
+ 'a;
|
||||
const MODPACK_CONTENT_DOWNLOAD_CONCURRENCY: usize = 4;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ModpackContentInstallContext {
|
||||
instance_id: String,
|
||||
instance_path: String,
|
||||
instance_full_path: PathBuf,
|
||||
download_meta: DownloadMeta,
|
||||
pack_version_id: Option<String>,
|
||||
pack_project_id: Option<String>,
|
||||
reporter: InstallProgressReporter,
|
||||
modpack_details: InstallPhaseDetails,
|
||||
content_progress: Arc<AtomicU64>,
|
||||
content_bytes_progress: Arc<AtomicU64>,
|
||||
active_download_bytes: Arc<Mutex<HashMap<String, u64>>>,
|
||||
file_infos_by_hash: Arc<HashMap<String, CachedFile>>,
|
||||
num_files: usize,
|
||||
content_total_bytes: u64,
|
||||
}
|
||||
|
||||
impl ModpackContentInstallContext {
|
||||
async fn mark_downloaded(
|
||||
&self,
|
||||
file_size: u64,
|
||||
event: InstallJobEventKind,
|
||||
) -> crate::Result<()> {
|
||||
let current = self.content_progress.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let current_bytes = self
|
||||
.content_bytes_progress
|
||||
.fetch_add(file_size, Ordering::Relaxed)
|
||||
+ file_size;
|
||||
|
||||
self.reporter
|
||||
.update_with_events(
|
||||
InstallPhaseId::DownloadingContent,
|
||||
Some(InstallProgress {
|
||||
current,
|
||||
total: self.num_files as u64,
|
||||
secondary: (self.content_total_bytes > 0).then_some(
|
||||
InstallProgressSecondary {
|
||||
current: current_bytes
|
||||
.min(self.content_total_bytes),
|
||||
total: self.content_total_bytes,
|
||||
},
|
||||
),
|
||||
}),
|
||||
self.modpack_details.clone(),
|
||||
vec![event],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove_active_download(&self, path: &str) {
|
||||
let mut active_download_bytes = self.active_download_bytes.lock().await;
|
||||
active_download_bytes.remove(path);
|
||||
}
|
||||
|
||||
async fn update_active_download(
|
||||
&self,
|
||||
path: String,
|
||||
downloaded: u64,
|
||||
) -> u64 {
|
||||
let mut active_download_bytes = self.active_download_bytes.lock().await;
|
||||
active_download_bytes.insert(path, downloaded);
|
||||
active_download_bytes.values().sum::<u64>()
|
||||
}
|
||||
}
|
||||
|
||||
enum MrpackZipReader {
|
||||
Memory(async_zip::tokio::read::seek::ZipFileReader<Cursor<bytes::Bytes>>),
|
||||
@@ -246,7 +320,17 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
||||
let version_id = create_pack.description.version_id;
|
||||
let instance_id = create_pack.description.instance_id;
|
||||
let mut icon_exists = icon.is_some();
|
||||
let source_path = pack_source_path(&file);
|
||||
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("read modpack archive")
|
||||
.maybe_project_id(project_id.clone())
|
||||
.maybe_version_id(version_id.clone())
|
||||
.source_path(source_path.clone())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
let mut zip_reader = MrpackZipReader::new(&file).await?;
|
||||
let instance_full_path =
|
||||
crate::api::instance::get_full_path(&instance_id).await?;
|
||||
@@ -262,6 +346,16 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
||||
modpack_details.clone(),
|
||||
)
|
||||
.await?;
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("read modpack manifest")
|
||||
.maybe_project_id(project_id.clone())
|
||||
.maybe_version_id(version_id.clone())
|
||||
.source_path(source_path.clone())
|
||||
.entry_path("modrinth.index.json")
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Extract index of modrinth.index.json
|
||||
let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| {
|
||||
@@ -413,7 +507,7 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
||||
.map(|file| file.file_size as u64)
|
||||
.sum::<u64>();
|
||||
reporter
|
||||
.update(
|
||||
.update_with_events(
|
||||
InstallPhaseId::DownloadingContent,
|
||||
Some(InstallProgress {
|
||||
current: 0,
|
||||
@@ -426,138 +520,276 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
||||
),
|
||||
}),
|
||||
modpack_details.clone(),
|
||||
vec![InstallJobEventKind::ContentDownloadStarted {
|
||||
files: num_files as u64,
|
||||
bytes: (content_total_bytes > 0).then_some(content_total_bytes),
|
||||
}],
|
||||
)
|
||||
.await?;
|
||||
let content_progress = Arc::new(AtomicU64::new(0));
|
||||
let content_bytes_progress = Arc::new(AtomicU64::new(0));
|
||||
let active_download_bytes =
|
||||
Arc::new(Mutex::new(HashMap::<String, u64>::new()));
|
||||
let file_info_hashes = pack
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|file| {
|
||||
file.hashes.get(&PackFileHash::Sha1).map(String::as_str)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let file_infos_by_hash = Arc::new(
|
||||
CachedEntry::get_file_many(
|
||||
&file_info_hashes,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|file| (file.hash.clone(), file))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
);
|
||||
let content_context = ModpackContentInstallContext {
|
||||
instance_id: instance_id.clone(),
|
||||
instance_path: instance_path.clone(),
|
||||
instance_full_path: instance_full_path.clone(),
|
||||
download_meta,
|
||||
pack_version_id: version_id.clone(),
|
||||
pack_project_id: project_id.clone(),
|
||||
reporter: reporter.clone(),
|
||||
modpack_details: modpack_details.clone(),
|
||||
content_progress,
|
||||
content_bytes_progress,
|
||||
active_download_bytes,
|
||||
file_infos_by_hash,
|
||||
num_files,
|
||||
content_total_bytes,
|
||||
};
|
||||
loading_try_for_each_concurrent(
|
||||
futures::stream::iter(pack.files).map(Ok::<PackFile, crate::Error>),
|
||||
None,
|
||||
Some(MODPACK_CONTENT_DOWNLOAD_CONCURRENCY),
|
||||
None,
|
||||
70.0,
|
||||
num_files,
|
||||
None,
|
||||
|project| {
|
||||
let instance_id = instance_id.clone();
|
||||
let instance_path = instance_path.clone();
|
||||
let instance_full_path = instance_full_path.clone();
|
||||
let download_meta = download_meta.clone();
|
||||
let pack_version_id = version_id.clone();
|
||||
let reporter = reporter.clone();
|
||||
let modpack_details = modpack_details.clone();
|
||||
let content_progress = content_progress.clone();
|
||||
let content_bytes_progress = content_bytes_progress.clone();
|
||||
let content_context = content_context.clone();
|
||||
async move {
|
||||
let mark_downloaded = |file_size: u64| {
|
||||
let reporter = reporter.clone();
|
||||
let modpack_details = modpack_details.clone();
|
||||
let content_progress = content_progress.clone();
|
||||
let content_bytes_progress = content_bytes_progress.clone();
|
||||
async move {
|
||||
let current = content_progress
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
+ 1;
|
||||
let current_bytes = content_bytes_progress
|
||||
.fetch_add(file_size, Ordering::Relaxed)
|
||||
+ file_size;
|
||||
reporter
|
||||
.update(
|
||||
InstallPhaseId::DownloadingContent,
|
||||
Some(InstallProgress {
|
||||
current,
|
||||
total: num_files as u64,
|
||||
secondary: (content_total_bytes > 0)
|
||||
.then_some(InstallProgressSecondary {
|
||||
current: current_bytes
|
||||
.min(content_total_bytes),
|
||||
total: content_total_bytes,
|
||||
}),
|
||||
}),
|
||||
modpack_details,
|
||||
)
|
||||
.await?;
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
};
|
||||
let project_size = project.file_size as u64;
|
||||
let project_path = project.path.as_str().to_string();
|
||||
let target_path = content_context
|
||||
.instance_full_path
|
||||
.join(project.path.as_str());
|
||||
|
||||
//TODO: Future update: prompt user for optional files in a modpack
|
||||
if let Some(env) = project.env
|
||||
if let Some(env) = project.env.as_ref()
|
||||
&& env
|
||||
.get(&EnvType::Client)
|
||||
.is_some_and(|x| x == &SideType::Unsupported)
|
||||
{
|
||||
mark_downloaded(project.file_size as u64).await?;
|
||||
content_context
|
||||
.mark_downloaded(
|
||||
project_size,
|
||||
InstallJobEventKind::ContentFileSkipped {
|
||||
path: project_path,
|
||||
reason: "unsupported on client".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let file = fetch_mirrors(
|
||||
let context =
|
||||
InstallErrorContext::new("download modpack content file")
|
||||
.maybe_project_id(
|
||||
content_context.pack_project_id.clone(),
|
||||
)
|
||||
.maybe_version_id(
|
||||
content_context.pack_version_id.clone(),
|
||||
)
|
||||
.file_path(project_path.clone())
|
||||
.target_path(target_path.display().to_string())
|
||||
.urls(project.downloads.clone())
|
||||
.maybe_expected_hash(
|
||||
project.hashes.get(&PackFileHash::Sha1).cloned(),
|
||||
)
|
||||
.expected_size(project_size)
|
||||
.build();
|
||||
content_context
|
||||
.reporter
|
||||
.set_transient_context(context.clone())
|
||||
.await?;
|
||||
|
||||
let progress_key = project_path.clone();
|
||||
let progress_context = content_context.clone();
|
||||
let min_download_progress_delta =
|
||||
(project_size / 200).max(256 * 1024);
|
||||
let mut last_reported_downloaded = 0_u64;
|
||||
let mut report_download_progress = move |downloaded: u64,
|
||||
_total_size: u64|
|
||||
-> Pin<Box<dyn Future<Output = crate::Result<()>> + Send>> {
|
||||
if downloaded < project_size
|
||||
&& downloaded.saturating_sub(last_reported_downloaded)
|
||||
< min_download_progress_delta
|
||||
{
|
||||
return Box::pin(async { Ok(()) });
|
||||
}
|
||||
|
||||
last_reported_downloaded = downloaded;
|
||||
let progress_context = progress_context.clone();
|
||||
let progress_key = progress_key.clone();
|
||||
Box::pin(async move {
|
||||
let active_bytes = progress_context
|
||||
.update_active_download(progress_key, downloaded)
|
||||
.await;
|
||||
let current_bytes = progress_context
|
||||
.content_bytes_progress
|
||||
.load(Ordering::Relaxed)
|
||||
.saturating_add(active_bytes)
|
||||
.min(progress_context.content_total_bytes);
|
||||
progress_context
|
||||
.reporter
|
||||
.update(
|
||||
InstallPhaseId::DownloadingContent,
|
||||
Some(InstallProgress {
|
||||
current: progress_context
|
||||
.content_progress
|
||||
.load(Ordering::Relaxed),
|
||||
total: progress_context.num_files as u64,
|
||||
secondary: (progress_context
|
||||
.content_total_bytes
|
||||
> 0)
|
||||
.then_some(InstallProgressSecondary {
|
||||
current: current_bytes,
|
||||
total: progress_context
|
||||
.content_total_bytes,
|
||||
}),
|
||||
}),
|
||||
progress_context.modpack_details.clone(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
let progress =
|
||||
&mut report_download_progress as &mut FetchProgressFn<'_>;
|
||||
let file = match fetch_mirrors_with_progress(
|
||||
&project
|
||||
.downloads
|
||||
.iter()
|
||||
.map(|x| &**x)
|
||||
.collect::<Vec<&str>>(),
|
||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
|
||||
Some(&download_meta),
|
||||
Some(&content_context.download_meta),
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
Some(progress),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(file) => {
|
||||
content_context
|
||||
.remove_active_download(&project_path)
|
||||
.await;
|
||||
file
|
||||
}
|
||||
Err(error) => {
|
||||
content_context
|
||||
.remove_active_download(&project_path)
|
||||
.await;
|
||||
content_context
|
||||
.reporter
|
||||
.persist_failure_context(context)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let downloaded_bytes = file.len() as u64;
|
||||
|
||||
let path = instance_full_path.join(project.path.as_str());
|
||||
let path = target_path;
|
||||
|
||||
cache_file_hash(
|
||||
file.clone(),
|
||||
&instance_path,
|
||||
project.path.as_str(),
|
||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
|
||||
ProjectType::get_from_parent_folder(&path),
|
||||
None,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let _permit = state.install_db_semaphore.acquire().await?;
|
||||
content_context
|
||||
.reporter
|
||||
.preserve_failure_context(
|
||||
context.clone(),
|
||||
cache_file_hash(
|
||||
file.clone(),
|
||||
&content_context.instance_path,
|
||||
project.path.as_str(),
|
||||
project
|
||||
.hashes
|
||||
.get(&PackFileHash::Sha1)
|
||||
.map(|x| &**x),
|
||||
ProjectType::get_from_parent_folder(&path),
|
||||
None,
|
||||
&state.pool,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
write(&path, &file, &state.io_semaphore).await?;
|
||||
content_context
|
||||
.reporter
|
||||
.preserve_failure_context(
|
||||
context.clone(),
|
||||
write(&path, &file, &state.io_semaphore).await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(project_type) =
|
||||
ProjectType::get_from_parent_folder(project.path.as_str())
|
||||
{
|
||||
let hash =
|
||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x);
|
||||
let file_info = if let Some(hash) = hash {
|
||||
CachedEntry::get_file_many(
|
||||
&[hash],
|
||||
None,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let file_info =
|
||||
hash.and_then(|hash| {
|
||||
content_context.file_infos_by_hash.get(hash)
|
||||
});
|
||||
if let Some(hash) = hash {
|
||||
crate::state::instances::commands::record_project_file(
|
||||
&instance_id,
|
||||
project.path.as_str(),
|
||||
hash,
|
||||
project.file_size as u64,
|
||||
project_type,
|
||||
modpack_source_kind(pack_version_id.as_deref()),
|
||||
file_info
|
||||
.as_ref()
|
||||
.map(|file| file.project_id.as_str()),
|
||||
file_info
|
||||
.as_ref()
|
||||
.map(|file| file.version_id.as_str()),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let _permit =
|
||||
state.install_db_semaphore.acquire().await?;
|
||||
content_context
|
||||
.reporter
|
||||
.preserve_failure_context(
|
||||
context.clone(),
|
||||
crate::state::instances::commands::record_project_file(
|
||||
&content_context.instance_id,
|
||||
project.path.as_str(),
|
||||
hash,
|
||||
project.file_size as u64,
|
||||
project_type,
|
||||
modpack_source_kind(
|
||||
content_context
|
||||
.pack_version_id
|
||||
.as_deref(),
|
||||
),
|
||||
file_info.map(|file| {
|
||||
file.project_id.as_str()
|
||||
}),
|
||||
file_info.map(|file| {
|
||||
file.version_id.as_str()
|
||||
}),
|
||||
state,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
mark_downloaded(project.file_size as u64).await?;
|
||||
content_context
|
||||
.mark_downloaded(
|
||||
project_size,
|
||||
InstallJobEventKind::ContentFileCompleted {
|
||||
path: project_path,
|
||||
bytes: downloaded_bytes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
@@ -646,7 +878,18 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
||||
|
||||
let path =
|
||||
instance_full_path.join(relative_override_file_path.as_str());
|
||||
let (size, hash) = if override_total_bytes > 0 {
|
||||
let override_context =
|
||||
InstallErrorContext::new("extract modpack override")
|
||||
.maybe_project_id(project_id.clone())
|
||||
.maybe_version_id(version_id.clone())
|
||||
.source_path(source_path.clone())
|
||||
.entry_path(file.filename().as_str().unwrap_or_default())
|
||||
.target_path(path.display().to_string())
|
||||
.build();
|
||||
reporter
|
||||
.set_transient_context(override_context.clone())
|
||||
.await?;
|
||||
let extract_result = if override_total_bytes > 0 {
|
||||
let progress =
|
||||
&mut report_override_progress as &mut ExtractProgressFn<'_>;
|
||||
zip_reader
|
||||
@@ -656,41 +899,65 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
||||
&state.io_semaphore,
|
||||
Some(progress),
|
||||
)
|
||||
.await?
|
||||
.await
|
||||
} else {
|
||||
zip_reader
|
||||
.extract_entry(index, &path, &state.io_semaphore, None)
|
||||
.await?
|
||||
.await
|
||||
};
|
||||
|
||||
crate::state::cache_file_hash_metadata(
|
||||
&instance_path,
|
||||
relative_override_file_path.as_str(),
|
||||
size,
|
||||
hash.clone(),
|
||||
ProjectType::get_from_parent_folder(
|
||||
relative_override_file_path.as_str(),
|
||||
),
|
||||
None,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(project_type) = ProjectType::get_from_parent_folder(
|
||||
relative_override_file_path.as_str(),
|
||||
) {
|
||||
crate::state::instances::commands::record_project_file(
|
||||
&instance_id,
|
||||
relative_override_file_path.as_str(),
|
||||
&hash,
|
||||
size,
|
||||
project_type,
|
||||
modpack_source_kind(version_id.as_deref()),
|
||||
None,
|
||||
None,
|
||||
state,
|
||||
)
|
||||
let (size, hash) = reporter
|
||||
.preserve_failure_context(override_context, extract_result)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let _permit = state.install_db_semaphore.acquire().await?;
|
||||
let record_context =
|
||||
InstallErrorContext::new("record modpack override")
|
||||
.maybe_project_id(project_id.clone())
|
||||
.maybe_version_id(version_id.clone())
|
||||
.source_path(source_path.clone())
|
||||
.entry_path(file.filename().as_str().unwrap_or_default())
|
||||
.target_path(path.display().to_string())
|
||||
.build();
|
||||
reporter
|
||||
.preserve_failure_context(
|
||||
record_context.clone(),
|
||||
crate::state::cache_file_hash_metadata(
|
||||
&instance_path,
|
||||
relative_override_file_path.as_str(),
|
||||
size,
|
||||
hash.clone(),
|
||||
ProjectType::get_from_parent_folder(
|
||||
relative_override_file_path.as_str(),
|
||||
),
|
||||
None,
|
||||
&state.pool,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(project_type) = ProjectType::get_from_parent_folder(
|
||||
relative_override_file_path.as_str(),
|
||||
) {
|
||||
reporter
|
||||
.preserve_failure_context(
|
||||
record_context,
|
||||
crate::state::instances::commands::record_project_file(
|
||||
&instance_id,
|
||||
relative_override_file_path.as_str(),
|
||||
&hash,
|
||||
size,
|
||||
project_type,
|
||||
modpack_source_kind(version_id.as_deref()),
|
||||
None,
|
||||
None,
|
||||
state,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,13 +972,21 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
|
||||
crate::launcher::install_minecraft_for_instance_id_with_reporter(
|
||||
&instance_id,
|
||||
false,
|
||||
Some(reporter),
|
||||
Some(reporter.clone()),
|
||||
)
|
||||
.await?;
|
||||
reporter.clear_context().await?;
|
||||
|
||||
Ok::<String, crate::Error>(instance_id.clone())
|
||||
}
|
||||
|
||||
fn pack_source_path(file: &CreatePackFile) -> String {
|
||||
match file {
|
||||
CreatePackFile::Bytes(_) => "downloaded mrpack bytes".to_string(),
|
||||
CreatePackFile::Path(path) => path.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn modpack_source_kind(version_id: Option<&str>) -> ContentSourceKind {
|
||||
if version_id.is_some() {
|
||||
ContentSourceKind::ModrinthModpack
|
||||
|
||||
@@ -12,6 +12,14 @@ use tracing_error::InstrumentError;
|
||||
pub struct LabrinthError {
|
||||
pub error: String,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<u16>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub route: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
use super::model::{
|
||||
InstallCleanup, InstallInterruptReason, InstallJobEvent,
|
||||
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
|
||||
InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||
};
|
||||
use super::store;
|
||||
use crate::state::{ModrinthCredentials, State};
|
||||
use regex::{Captures, Regex};
|
||||
use sqlx::Row;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const INSTALL_SUPPORT_LOG_TAIL_BYTES: u64 = 128 * 1024;
|
||||
|
||||
pub async fn build_job_support_details(
|
||||
job: &store::InstallJobRecord,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let snapshot = job.snapshot();
|
||||
let mut details = String::new();
|
||||
let title = snapshot
|
||||
.display
|
||||
.as_ref()
|
||||
.map(|display| display.title.as_str())
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
let _ = writeln!(details, "Install report: {title}");
|
||||
let _ =
|
||||
writeln!(details, "Result: {}", result_summary(&snapshot, &job.state));
|
||||
let _ = writeln!(details, "Job ID: {}", snapshot.job_id);
|
||||
let _ = writeln!(details, "Request: {}", json_string(&snapshot.kind));
|
||||
let _ = writeln!(details, "Status: {}", json_string(&snapshot.status));
|
||||
let _ = writeln!(details, "Current phase: {}", phase_label(snapshot.phase));
|
||||
if let Some(progress) = &snapshot.progress {
|
||||
let _ = writeln!(
|
||||
details,
|
||||
"Current progress: {}",
|
||||
progress_summary(progress)
|
||||
);
|
||||
}
|
||||
|
||||
write_environment_details(&mut details);
|
||||
write_timeline(&mut details, &job.state.events);
|
||||
write_content_summary(&mut details, &job.state.events);
|
||||
write_errors(&mut details, &snapshot);
|
||||
write_raw_snapshot(&mut details, &snapshot);
|
||||
write_latest_log(&mut details, state).await;
|
||||
|
||||
censor_support_text(details, state).await
|
||||
}
|
||||
|
||||
fn result_summary(
|
||||
snapshot: &InstallJobSnapshot,
|
||||
state: &InstallJobState,
|
||||
) -> String {
|
||||
match snapshot.status {
|
||||
InstallJobStatus::Queued => "queued".to_string(),
|
||||
InstallJobStatus::Running => {
|
||||
format!("running while {}", phase_label(snapshot.phase))
|
||||
}
|
||||
InstallJobStatus::Succeeded => "succeeded".to_string(),
|
||||
InstallJobStatus::Canceled => snapshot
|
||||
.error
|
||||
.as_ref()
|
||||
.and_then(|error| error.phase)
|
||||
.map(|phase| format!("canceled while {}", phase_label(phase)))
|
||||
.unwrap_or_else(|| "canceled".to_string()),
|
||||
InstallJobStatus::Failed => snapshot
|
||||
.error
|
||||
.as_ref()
|
||||
.and_then(|error| {
|
||||
error.phase.map(|phase| {
|
||||
format!(
|
||||
"failed while {} ({})",
|
||||
phase_label(phase),
|
||||
error.code
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "failed".to_string()),
|
||||
InstallJobStatus::Interrupted => latest_interruption(&state.events)
|
||||
.map(|(reason, phase)| match reason {
|
||||
InstallInterruptReason::AppClosed => format!(
|
||||
"interrupted because the app closed while {}",
|
||||
phase_label(phase)
|
||||
),
|
||||
InstallInterruptReason::Unknown => {
|
||||
format!("interrupted while {}", phase_label(phase))
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "interrupted".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_environment_details(details: &mut String) {
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Environment");
|
||||
let _ = writeln!(details, "App version: {}", env!("CARGO_PKG_VERSION"));
|
||||
let _ = writeln!(
|
||||
details,
|
||||
"OS: {}",
|
||||
sysinfo::System::long_os_version()
|
||||
.or_else(sysinfo::System::name)
|
||||
.unwrap_or_else(|| std::env::consts::OS.to_string())
|
||||
);
|
||||
let _ = writeln!(details, "OS kind: {}", std::env::consts::OS);
|
||||
let _ = writeln!(details, "OS family: {}", std::env::consts::FAMILY);
|
||||
let _ = writeln!(details, "Architecture: {}", std::env::consts::ARCH);
|
||||
if let Some(kernel_version) = sysinfo::System::kernel_version() {
|
||||
let _ = writeln!(details, "Kernel: {kernel_version}");
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_interruption(
|
||||
events: &[InstallJobEvent],
|
||||
) -> Option<(InstallInterruptReason, InstallPhaseId)> {
|
||||
events.iter().rev().find_map(|event| match &event.kind {
|
||||
InstallJobEventKind::Interrupted { reason, phase } => {
|
||||
Some((*reason, *phase))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_timeline(details: &mut String, events: &[InstallJobEvent]) {
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Timeline");
|
||||
|
||||
let mut index = 1;
|
||||
for event in events {
|
||||
let Some(description) = timeline_event_description(event) else {
|
||||
continue;
|
||||
};
|
||||
let _ = writeln!(
|
||||
details,
|
||||
"{index}. {} {description}",
|
||||
event.at.to_rfc3339()
|
||||
);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if index == 1 {
|
||||
let _ = writeln!(details, "No install events were recorded.");
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_event_description(event: &InstallJobEvent) -> Option<String> {
|
||||
match &event.kind {
|
||||
InstallJobEventKind::JobQueued { kind } => {
|
||||
Some(format!("Queued {} install", json_string(kind)))
|
||||
}
|
||||
InstallJobEventKind::JobStarted => {
|
||||
Some("Started install job".to_string())
|
||||
}
|
||||
InstallJobEventKind::JobSucceeded { instance_id } => {
|
||||
Some(match instance_id {
|
||||
Some(instance_id) => {
|
||||
format!("Finished install for instance {instance_id}")
|
||||
}
|
||||
None => "Finished install".to_string(),
|
||||
})
|
||||
}
|
||||
InstallJobEventKind::JobCanceled { phase } => {
|
||||
Some(format!("Canceled while {}", phase_label(*phase)))
|
||||
}
|
||||
InstallJobEventKind::PhaseStarted { phase, details } => Some(format!(
|
||||
"Started {}{}",
|
||||
phase_label(*phase),
|
||||
phase_details_suffix(details)
|
||||
)),
|
||||
InstallJobEventKind::Interrupted { reason, phase } => {
|
||||
Some(match reason {
|
||||
InstallInterruptReason::AppClosed => {
|
||||
format!("App closed while {}", phase_label(*phase))
|
||||
}
|
||||
InstallInterruptReason::Unknown => {
|
||||
format!("Interrupted while {}", phase_label(*phase))
|
||||
}
|
||||
})
|
||||
}
|
||||
InstallJobEventKind::Failed {
|
||||
phase,
|
||||
code,
|
||||
message,
|
||||
} => Some(format!(
|
||||
"Failed while {} ({code}): {message}",
|
||||
phase_label(*phase)
|
||||
)),
|
||||
InstallJobEventKind::RollbackStarted { cleanup } => {
|
||||
Some(format!("Started rollback ({})", cleanup_summary(cleanup)))
|
||||
}
|
||||
InstallJobEventKind::RollbackCompleted => {
|
||||
Some("Rollback completed".to_string())
|
||||
}
|
||||
InstallJobEventKind::RollbackFailed { message } => {
|
||||
Some(format!("Rollback failed: {message}"))
|
||||
}
|
||||
InstallJobEventKind::ContentDownloadStarted { .. }
|
||||
| InstallJobEventKind::ContentFileSkipped { .. }
|
||||
| InstallJobEventKind::ContentFileCompleted { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_content_summary(details: &mut String, events: &[InstallJobEvent]) {
|
||||
let started = events.iter().rev().find_map(|event| match &event.kind {
|
||||
InstallJobEventKind::ContentDownloadStarted { files, bytes } => {
|
||||
Some((*files, *bytes))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let completed = events
|
||||
.iter()
|
||||
.filter_map(|event| match &event.kind {
|
||||
InstallJobEventKind::ContentFileCompleted { path, bytes } => {
|
||||
Some((path.as_str(), *bytes))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let skipped = events
|
||||
.iter()
|
||||
.filter_map(|event| match &event.kind {
|
||||
InstallJobEventKind::ContentFileSkipped { path, reason } => {
|
||||
Some((path.as_str(), reason.as_str()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if started.is_none() && completed.is_empty() && skipped.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Content activity");
|
||||
if let Some((files, bytes)) = started {
|
||||
let _ = writeln!(
|
||||
details,
|
||||
"Completed files: {} / {files}, skipped files: {}",
|
||||
completed.len(),
|
||||
skipped.len()
|
||||
);
|
||||
if let Some(bytes) = bytes {
|
||||
let _ = writeln!(
|
||||
details,
|
||||
"Expected content size: {}",
|
||||
format_bytes(bytes)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let _ = writeln!(
|
||||
details,
|
||||
"Completed files: {}, skipped files: {}",
|
||||
completed.len(),
|
||||
skipped.len()
|
||||
);
|
||||
}
|
||||
|
||||
if !completed.is_empty() {
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Recently completed files");
|
||||
for (path, bytes) in completed.iter().rev().take(20) {
|
||||
let _ = writeln!(details, "- {path} ({})", format_bytes(*bytes));
|
||||
}
|
||||
if completed.len() > 20 {
|
||||
let _ = writeln!(details, "- ... {} more", completed.len() - 20);
|
||||
}
|
||||
}
|
||||
|
||||
if !skipped.is_empty() {
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Skipped files");
|
||||
for (path, reason) in skipped.iter().rev().take(20) {
|
||||
let _ = writeln!(details, "- {path} ({reason})");
|
||||
}
|
||||
if skipped.len() > 20 {
|
||||
let _ = writeln!(details, "- ... {} more", skipped.len() - 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_errors(details: &mut String, snapshot: &InstallJobSnapshot) {
|
||||
if let Some(error) = &snapshot.error {
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Failure");
|
||||
let _ = writeln!(details, "Code: {}", error.code);
|
||||
if let Some(phase) = error.phase {
|
||||
let _ = writeln!(details, "Phase: {}", phase_label(phase));
|
||||
}
|
||||
let _ = writeln!(details, "Message: {}", error.message);
|
||||
write_api_error_details(details, error);
|
||||
write_error_context(details, error);
|
||||
}
|
||||
|
||||
if let Some(error) = &snapshot.rollback_error {
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Rollback error");
|
||||
let _ = writeln!(details, "Code: {}", error.code);
|
||||
if let Some(phase) = error.phase {
|
||||
let _ = writeln!(details, "Phase: {}", phase_label(phase));
|
||||
}
|
||||
let _ = writeln!(details, "Message: {}", error.message);
|
||||
write_api_error_details(details, error);
|
||||
write_error_context(details, error);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_api_error_details(
|
||||
details: &mut String,
|
||||
error: &super::model::InstallErrorView,
|
||||
) {
|
||||
let Some(api) = &error.api else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = writeln!(details, "API error: {}", api.error);
|
||||
if let Some(status) = api.status {
|
||||
let _ = writeln!(details, "HTTP status: {status}");
|
||||
}
|
||||
if api.method.is_some() || api.url.is_some() {
|
||||
let method = api.method.as_deref().unwrap_or("unknown method");
|
||||
let url = api.url.as_deref().unwrap_or("unknown URL");
|
||||
let _ = writeln!(details, "Request: {method} {url}");
|
||||
}
|
||||
if let Some(route) = &api.route {
|
||||
let _ = writeln!(details, "Route: {route}");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_error_context(
|
||||
details: &mut String,
|
||||
error: &super::model::InstallErrorView,
|
||||
) {
|
||||
let Some(context) = &error.context else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = writeln!(details, "Operation: {}", context.operation);
|
||||
if let Some(source_path) = &context.source_path {
|
||||
let _ = writeln!(details, "Source path: {source_path}");
|
||||
}
|
||||
if let Some(target_path) = &context.target_path {
|
||||
let _ = writeln!(details, "Target path: {target_path}");
|
||||
}
|
||||
if let Some(file_path) = &context.file_path {
|
||||
let _ = writeln!(details, "File path: {file_path}");
|
||||
}
|
||||
if let Some(entry_path) = &context.entry_path {
|
||||
let _ = writeln!(details, "Archive entry: {entry_path}");
|
||||
}
|
||||
if !context.urls.is_empty() {
|
||||
let _ = writeln!(details, "URLs:");
|
||||
for url in &context.urls {
|
||||
let _ = writeln!(details, "- {url}");
|
||||
}
|
||||
}
|
||||
if let Some(expected_hash) = &context.expected_hash {
|
||||
let _ = writeln!(details, "Expected hash: {expected_hash}");
|
||||
}
|
||||
if let Some(expected_size) = context.expected_size {
|
||||
let _ =
|
||||
writeln!(details, "Expected size: {}", format_bytes(expected_size));
|
||||
}
|
||||
if let Some(project_id) = &context.project_id {
|
||||
let _ = writeln!(details, "Project ID: {project_id}");
|
||||
}
|
||||
if let Some(version_id) = &context.version_id {
|
||||
let _ = writeln!(details, "Version ID: {version_id}");
|
||||
}
|
||||
if let Some(minecraft_version) = &context.minecraft_version {
|
||||
let _ = writeln!(details, "Minecraft version: {minecraft_version}");
|
||||
}
|
||||
if let Some(loader) = &context.loader {
|
||||
let _ = writeln!(details, "Loader: {loader}");
|
||||
}
|
||||
if let Some(java_version) = context.java_version {
|
||||
let _ = writeln!(details, "Java version: {java_version}");
|
||||
}
|
||||
if let Some(os) = &context.os {
|
||||
let _ = writeln!(details, "OS: {os}");
|
||||
}
|
||||
if let Some(arch) = &context.arch {
|
||||
let _ = writeln!(details, "Architecture: {arch}");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_raw_snapshot(details: &mut String, snapshot: &InstallJobSnapshot) {
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Raw snapshot");
|
||||
match serde_json::to_string_pretty(snapshot) {
|
||||
Ok(snapshot_json) => {
|
||||
let _ = writeln!(details, "{snapshot_json}");
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = writeln!(details, "Unable to serialize snapshot: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_latest_log(details: &mut String, state: &State) {
|
||||
let _ = writeln!(details);
|
||||
let _ = writeln!(details, "Latest launcher log excerpt");
|
||||
match latest_launcher_log_tail(state).await {
|
||||
Ok(Some((path, output))) => {
|
||||
let _ = writeln!(details, "File: {}", path.display());
|
||||
details.push_str(&output);
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = writeln!(details, "No launcher log found.");
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = writeln!(details, "Unable to read launcher log: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn phase_label(phase: InstallPhaseId) -> &'static str {
|
||||
match phase {
|
||||
InstallPhaseId::PreparingInstance => "preparing instance",
|
||||
InstallPhaseId::ResolvingPack => "resolving pack",
|
||||
InstallPhaseId::DownloadingPackFile => "downloading pack file",
|
||||
InstallPhaseId::ReadingPackManifest => "reading pack manifest",
|
||||
InstallPhaseId::DownloadingContent => "downloading content",
|
||||
InstallPhaseId::ExtractingOverrides => "extracting overrides",
|
||||
InstallPhaseId::ResolvingMinecraft => "resolving Minecraft",
|
||||
InstallPhaseId::ResolvingLoader => "resolving loader",
|
||||
InstallPhaseId::PreparingJava => "preparing Java",
|
||||
InstallPhaseId::DownloadingMinecraft => "downloading Minecraft",
|
||||
InstallPhaseId::RunningLoaderProcessors => "running loader processors",
|
||||
InstallPhaseId::Finalizing => "finalizing",
|
||||
InstallPhaseId::RollingBack => "rolling back",
|
||||
}
|
||||
}
|
||||
|
||||
fn phase_details_suffix(details: &InstallPhaseDetails) -> String {
|
||||
match details {
|
||||
InstallPhaseDetails::Empty => String::new(),
|
||||
InstallPhaseDetails::Instance { name } => format!(" for {name}"),
|
||||
InstallPhaseDetails::Minecraft {
|
||||
game_version,
|
||||
loader,
|
||||
} => format!(
|
||||
" for Minecraft {game_version} with {}",
|
||||
json_string(loader)
|
||||
),
|
||||
InstallPhaseDetails::Java {
|
||||
major_version,
|
||||
step,
|
||||
} => format!(": {} Java {major_version}", json_string(step)),
|
||||
InstallPhaseDetails::Modpack {
|
||||
project_id,
|
||||
version_id,
|
||||
title,
|
||||
} => {
|
||||
let mut value = title
|
||||
.as_ref()
|
||||
.map(|title| format!(" for {title}"))
|
||||
.unwrap_or_default();
|
||||
if let Some(project_id) = project_id {
|
||||
let _ = write!(value, " project={project_id}");
|
||||
}
|
||||
if let Some(version_id) = version_id {
|
||||
let _ = write!(value, " version={version_id}");
|
||||
}
|
||||
value
|
||||
}
|
||||
InstallPhaseDetails::Import {
|
||||
launcher_type,
|
||||
instance_folder,
|
||||
} => format!(" from {launcher_type} instance {instance_folder}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_summary(cleanup: &InstallCleanup) -> String {
|
||||
match cleanup {
|
||||
InstallCleanup::DeleteNewInstance { instance_id } => {
|
||||
match instance_id {
|
||||
Some(instance_id) => {
|
||||
format!("delete partially-created instance {instance_id}")
|
||||
}
|
||||
None => "delete partially-created instance".to_string(),
|
||||
}
|
||||
}
|
||||
InstallCleanup::RestoreExistingInstance { instance_id } => {
|
||||
format!("restore existing instance {instance_id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_summary(progress: &InstallProgress) -> String {
|
||||
let mut value = format!("{} / {}", progress.current, progress.total);
|
||||
if let Some(secondary) = &progress.secondary {
|
||||
let _ = write!(
|
||||
value,
|
||||
" ({} / {})",
|
||||
format_bytes(secondary.current),
|
||||
format_bytes(secondary.total)
|
||||
);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = UNITS[0];
|
||||
for next_unit in UNITS.iter().skip(1) {
|
||||
if value < 1024.0 {
|
||||
break;
|
||||
}
|
||||
value /= 1024.0;
|
||||
unit = next_unit;
|
||||
}
|
||||
|
||||
if unit == "B" {
|
||||
format!("{bytes} B")
|
||||
} else {
|
||||
format!("{value:.1} {unit}")
|
||||
}
|
||||
}
|
||||
|
||||
fn json_string<T: serde::Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value)
|
||||
.map(|value| value.trim_matches('"').to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
async fn latest_launcher_log_tail(
|
||||
state: &State,
|
||||
) -> crate::Result<Option<(PathBuf, String)>> {
|
||||
let Some(logs_dir) = state.directories.launcher_logs_dir() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let entries = match std::fs::read_dir(&logs_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
let mut latest: Option<(PathBuf, SystemTime)> = None;
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(metadata) if metadata.is_file() => metadata,
|
||||
_ => continue,
|
||||
};
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.or_else(|_| metadata.created())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
let path = entry.path();
|
||||
|
||||
match latest.as_ref() {
|
||||
Some((_, latest_modified)) if modified <= *latest_modified => {}
|
||||
_ => latest = Some((path, modified)),
|
||||
}
|
||||
}
|
||||
|
||||
let Some((path, _)) = latest else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let output = read_file_tail(&path, INSTALL_SUPPORT_LOG_TAIL_BYTES)?;
|
||||
Ok(Some((path, output)))
|
||||
}
|
||||
|
||||
fn read_file_tail(path: &Path, max_bytes: u64) -> crate::Result<String> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let len = file.metadata()?.len();
|
||||
let start = len.saturating_sub(max_bytes);
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
|
||||
let mut buffer = Vec::with_capacity((len - start) as usize);
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
let mut output = String::from_utf8_lossy(&buffer).into_owned();
|
||||
if start > 0 {
|
||||
output = format!("[first {start} bytes omitted]\n{output}");
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn censor_support_text(
|
||||
mut text: String,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
for credentials in ModrinthCredentials::get_all(&state.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|credentials| credentials.1)
|
||||
{
|
||||
replace_nonempty(
|
||||
&mut text,
|
||||
&credentials.session,
|
||||
"{MODRINTH_ACCESS_TOKEN}",
|
||||
);
|
||||
}
|
||||
|
||||
for token in minecraft_tokens(&state.pool).await? {
|
||||
replace_nonempty(&mut text, &token, "{MINECRAFT_TOKEN}");
|
||||
}
|
||||
|
||||
text = censor_ip_addresses(text);
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
async fn minecraft_tokens(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT access_token, refresh_token FROM minecraft_users")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let mut tokens = Vec::with_capacity(rows.len() * 2);
|
||||
|
||||
for row in rows {
|
||||
tokens.push(row.try_get("access_token")?);
|
||||
tokens.push(row.try_get("refresh_token")?);
|
||||
}
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn replace_nonempty(text: &mut String, value: &str, replacement: &str) {
|
||||
if !value.is_empty() {
|
||||
*text = text.replace(value, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
fn censor_ip_addresses(text: String) -> String {
|
||||
let text = Regex::new(
|
||||
r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b",
|
||||
)
|
||||
.expect("valid IPv4 regex")
|
||||
.replace_all(&text, |captures: &Captures<'_>| {
|
||||
let value = &captures[0];
|
||||
match value.parse::<Ipv4Addr>() {
|
||||
Ok(_) => "...".to_string(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
})
|
||||
.into_owned();
|
||||
|
||||
Regex::new(r"(?i)\b[0-9a-f:.%]{3,}\b")
|
||||
.expect("valid IPv6 candidate regex")
|
||||
.replace_all(&text, |captures: &Captures<'_>| {
|
||||
let value = &captures[0];
|
||||
if value.matches(':').count() < 2 {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let candidate = value.split('%').next().unwrap_or(value);
|
||||
match candidate.parse::<Ipv6Addr>() {
|
||||
Ok(_) => ":::::::".to_string(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
use super::model::{
|
||||
InstallJobSnapshot, InstallJobState, InstallPhaseDetails, InstallPhaseId,
|
||||
InstallProgress,
|
||||
InstallErrorContext, InstallJobEventKind, InstallJobSnapshot,
|
||||
InstallJobState, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||
};
|
||||
use super::store;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
const PROGRESS_PERSIST_INTERVAL: Duration = Duration::from_millis(750);
|
||||
const CONTENT_PROGRESS_PERSIST_STEPS: u64 = 25;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InstallProgressReporter {
|
||||
job_id: Uuid,
|
||||
state: Arc<Mutex<InstallJobState>>,
|
||||
state: Arc<Mutex<InstallProgressReporterState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InstallProgressReporterState {
|
||||
job: InstallJobState,
|
||||
last_persisted_at: Instant,
|
||||
last_persisted_progress: Option<(InstallPhaseId, u64)>,
|
||||
}
|
||||
|
||||
impl InstallProgressReporter {
|
||||
pub fn new(job_id: Uuid, state: InstallJobState) -> Self {
|
||||
Self {
|
||||
job_id,
|
||||
state: Arc::new(Mutex::new(state)),
|
||||
state: Arc::new(Mutex::new(InstallProgressReporterState {
|
||||
job: state,
|
||||
last_persisted_at: Instant::now(),
|
||||
last_persisted_progress: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +42,156 @@ impl InstallProgressReporter {
|
||||
progress: Option<InstallProgress>,
|
||||
details: InstallPhaseDetails,
|
||||
) -> crate::Result<()> {
|
||||
let app_state = crate::State::get().await?;
|
||||
self.update_with_events(phase, progress, details, Vec::new())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_context(
|
||||
&self,
|
||||
context: InstallErrorContext,
|
||||
) -> crate::Result<()> {
|
||||
self.update_context(Some(context), true).await
|
||||
}
|
||||
|
||||
pub async fn set_transient_context(
|
||||
&self,
|
||||
context: InstallErrorContext,
|
||||
) -> crate::Result<()> {
|
||||
self.update_context(Some(context), false).await
|
||||
}
|
||||
|
||||
pub async fn clear_context(&self) -> crate::Result<()> {
|
||||
self.update_context(None, true).await
|
||||
}
|
||||
|
||||
async fn update_context(
|
||||
&self,
|
||||
context: Option<InstallErrorContext>,
|
||||
persist: bool,
|
||||
) -> crate::Result<()> {
|
||||
let app_state = if persist {
|
||||
Some(crate::State::get().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut state = self.state.lock().await;
|
||||
state.progress.phase = phase;
|
||||
state.progress.progress = progress;
|
||||
state.progress.details = details;
|
||||
state.job.set_context(context);
|
||||
|
||||
let Some(app_state) = app_state else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let record =
|
||||
store::update_state(self.job_id, &state, &app_state).await?;
|
||||
store::update_state(self.job_id, &state.job, &app_state).await?;
|
||||
state.mark_persisted();
|
||||
emit_install_job(&record.snapshot()).await
|
||||
}
|
||||
|
||||
pub async fn persist(&self) -> crate::Result<InstallJobSnapshot> {
|
||||
let app_state = crate::State::get().await?;
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
let record =
|
||||
store::update_state(self.job_id, &state.job, &app_state).await?;
|
||||
state.mark_persisted();
|
||||
let snapshot = record.snapshot();
|
||||
emit_install_job(&snapshot).await?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub async fn persist_failure_context(&self, context: InstallErrorContext) {
|
||||
if let Err(error) = self.update_context(Some(context), true).await {
|
||||
tracing::warn!(
|
||||
"Failed to persist install context for failed operation: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn preserve_failure_context<T>(
|
||||
&self,
|
||||
context: InstallErrorContext,
|
||||
result: crate::Result<T>,
|
||||
) -> crate::Result<T> {
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(error) => {
|
||||
self.persist_failure_context(context).await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_with_events(
|
||||
&self,
|
||||
phase: InstallPhaseId,
|
||||
progress: Option<InstallProgress>,
|
||||
details: InstallPhaseDetails,
|
||||
events: Vec<InstallJobEventKind>,
|
||||
) -> crate::Result<()> {
|
||||
let app_state = crate::State::get().await?;
|
||||
let mut state = self.state.lock().await;
|
||||
let phase_started = state.job.progress.phase != phase
|
||||
|| matches!(
|
||||
&state.job.progress.details,
|
||||
InstallPhaseDetails::Empty
|
||||
) && !matches!(&details, InstallPhaseDetails::Empty);
|
||||
|
||||
state.job.set_progress(phase, progress, details);
|
||||
for event in events {
|
||||
state.job.record_event(event);
|
||||
}
|
||||
|
||||
if !state.should_persist(phase_started) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let record =
|
||||
store::update_state(self.job_id, &state.job, &app_state).await?;
|
||||
state.mark_persisted();
|
||||
emit_install_job(&record.snapshot()).await
|
||||
}
|
||||
}
|
||||
|
||||
impl InstallProgressReporterState {
|
||||
fn should_persist(&self, phase_started: bool) -> bool {
|
||||
if phase_started {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(progress) = &self.job.progress.progress else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if progress.current >= progress.total {
|
||||
return true;
|
||||
}
|
||||
|
||||
let progressed_enough =
|
||||
if self.job.progress.phase == InstallPhaseId::DownloadingContent {
|
||||
self.last_persisted_progress
|
||||
.map(|(phase, current)| {
|
||||
phase != self.job.progress.phase
|
||||
|| progress.current.saturating_sub(current)
|
||||
>= CONTENT_PROGRESS_PERSIST_STEPS
|
||||
})
|
||||
.unwrap_or(true)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
progressed_enough
|
||||
|| self.last_persisted_at.elapsed() >= PROGRESS_PERSIST_INTERVAL
|
||||
}
|
||||
|
||||
fn mark_persisted(&mut self) {
|
||||
self.last_persisted_at = Instant::now();
|
||||
self.last_persisted_progress = self
|
||||
.job
|
||||
.progress
|
||||
.progress
|
||||
.as_ref()
|
||||
.map(|progress| (self.job.progress.phase, progress.current));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod diagnostics;
|
||||
pub mod events;
|
||||
pub mod model;
|
||||
pub mod recovery;
|
||||
@@ -6,13 +7,15 @@ pub mod store;
|
||||
|
||||
pub use events::InstallProgressReporter;
|
||||
pub use model::{
|
||||
InstallErrorView, InstallJavaStep, InstallJobKind, InstallJobSnapshot,
|
||||
InstallJobStatus, InstallModpackPreview, InstallPhaseDetails,
|
||||
InstallPhaseId, InstallPostInstallEdit, InstallProgress,
|
||||
InstallProgressSecondary, InstallRequest,
|
||||
InstallErrorContext, InstallErrorView, InstallJavaStep,
|
||||
InstallJobEventKind, 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,
|
||||
install_pack_to_existing_instance, job_support_details, list_jobs,
|
||||
retry_job,
|
||||
};
|
||||
|
||||
@@ -18,16 +18,23 @@ pub struct InstallJobState {
|
||||
pub cleanup: InstallCleanup,
|
||||
pub progress: InstallProgressState,
|
||||
pub paths: InstallJobPaths,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<InstallErrorContext>,
|
||||
#[serde(default)]
|
||||
pub events: Vec<InstallJobEvent>,
|
||||
#[serde(default)]
|
||||
pub display: Option<InstallJobDisplay>,
|
||||
pub rollback: Option<InstallRollbackState>,
|
||||
pub error: Option<InstallErrorView>,
|
||||
#[serde(default)]
|
||||
pub rollback_error: Option<InstallErrorView>,
|
||||
}
|
||||
|
||||
impl InstallJobState {
|
||||
pub fn new(request: InstallRequest) -> Self {
|
||||
let target = request.target();
|
||||
let cleanup = request.cleanup();
|
||||
let kind = request.kind();
|
||||
let phase = InstallPhaseId::PreparingInstance;
|
||||
|
||||
Self {
|
||||
@@ -41,11 +48,109 @@ impl InstallJobState {
|
||||
details: InstallPhaseDetails::Empty,
|
||||
},
|
||||
paths: InstallJobPaths::default(),
|
||||
context: None,
|
||||
events: vec![InstallJobEvent {
|
||||
at: Utc::now(),
|
||||
kind: InstallJobEventKind::JobQueued { kind },
|
||||
}],
|
||||
display: None,
|
||||
rollback: None,
|
||||
error: None,
|
||||
rollback_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_event(&mut self, kind: InstallJobEventKind) {
|
||||
self.events.push(InstallJobEvent {
|
||||
at: Utc::now(),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_context(&mut self, context: Option<InstallErrorContext>) {
|
||||
self.context = context;
|
||||
}
|
||||
|
||||
pub fn set_progress(
|
||||
&mut self,
|
||||
phase: InstallPhaseId,
|
||||
progress: Option<InstallProgress>,
|
||||
details: InstallPhaseDetails,
|
||||
) {
|
||||
if self.progress.phase != phase
|
||||
|| matches!(&self.progress.details, InstallPhaseDetails::Empty)
|
||||
&& !matches!(&details, InstallPhaseDetails::Empty)
|
||||
{
|
||||
self.record_event(InstallJobEventKind::PhaseStarted {
|
||||
phase,
|
||||
details: details.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
self.progress.phase = phase;
|
||||
self.progress.progress = progress;
|
||||
self.progress.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallJobEvent {
|
||||
pub at: DateTime<Utc>,
|
||||
pub kind: InstallJobEventKind,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstallInterruptReason {
|
||||
AppClosed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InstallJobEventKind {
|
||||
JobQueued {
|
||||
kind: InstallJobKind,
|
||||
},
|
||||
JobStarted,
|
||||
JobSucceeded {
|
||||
instance_id: Option<String>,
|
||||
},
|
||||
JobCanceled {
|
||||
phase: InstallPhaseId,
|
||||
},
|
||||
PhaseStarted {
|
||||
phase: InstallPhaseId,
|
||||
details: InstallPhaseDetails,
|
||||
},
|
||||
ContentDownloadStarted {
|
||||
files: u64,
|
||||
bytes: Option<u64>,
|
||||
},
|
||||
ContentFileSkipped {
|
||||
path: String,
|
||||
reason: String,
|
||||
},
|
||||
ContentFileCompleted {
|
||||
path: String,
|
||||
bytes: u64,
|
||||
},
|
||||
Interrupted {
|
||||
reason: InstallInterruptReason,
|
||||
phase: InstallPhaseId,
|
||||
},
|
||||
Failed {
|
||||
phase: InstallPhaseId,
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
RollbackStarted {
|
||||
cleanup: InstallCleanup,
|
||||
},
|
||||
RollbackCompleted,
|
||||
RollbackFailed {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
@@ -315,6 +420,51 @@ pub struct InstallJobPaths {
|
||||
pub final_instance_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, bon::Builder)]
|
||||
#[builder(start_fn = new)]
|
||||
pub struct InstallErrorContext {
|
||||
#[builder(start_fn, into)]
|
||||
pub operation: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub source_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub target_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub file_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub entry_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[builder(default)]
|
||||
pub urls: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub expected_hash: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_size: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub minecraft_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub loader: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub java_version: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub os: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[builder(into)]
|
||||
pub arch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallJobDisplay {
|
||||
pub title: String,
|
||||
@@ -330,14 +480,66 @@ pub struct InstallRollbackState {
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallErrorView {
|
||||
pub code: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<InstallPhaseId>,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<InstallApiErrorDetails>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<InstallErrorContext>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct InstallApiErrorDetails {
|
||||
pub error: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<u16>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub route: Option<String>,
|
||||
}
|
||||
|
||||
impl InstallErrorView {
|
||||
pub fn from_error(code: &str, error: impl ToString) -> Self {
|
||||
pub fn from_error(
|
||||
code: &str,
|
||||
phase: InstallPhaseId,
|
||||
error: &crate::Error,
|
||||
context: Option<InstallErrorContext>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
phase: Some(phase),
|
||||
message: error.to_string(),
|
||||
api: match error.raw.as_ref() {
|
||||
crate::ErrorKind::LabrinthError(error) => {
|
||||
Some(InstallApiErrorDetails {
|
||||
error: error.error.clone(),
|
||||
status: error.status,
|
||||
method: error.method.clone(),
|
||||
url: error.url.clone(),
|
||||
route: error.route.clone(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_message(
|
||||
code: &str,
|
||||
phase: InstallPhaseId,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
phase: Some(phase),
|
||||
message: message.into(),
|
||||
api: None,
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,6 +556,7 @@ pub struct InstallJobSnapshot {
|
||||
pub details: InstallPhaseDetails,
|
||||
pub display: Option<InstallJobDisplay>,
|
||||
pub error: Option<InstallErrorView>,
|
||||
pub rollback_error: Option<InstallErrorView>,
|
||||
pub created: DateTime<Utc>,
|
||||
pub modified: DateTime<Utc>,
|
||||
pub finished: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::events::emit_install_job;
|
||||
use super::model::{
|
||||
InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobState,
|
||||
InstallJobStatus, InstallPhaseDetails, InstallPhaseId, InstallRequest,
|
||||
InstallTarget,
|
||||
InstallCleanup, InstallErrorView, InstallInterruptReason,
|
||||
InstallJobDisplay, InstallJobEventKind, InstallJobState, InstallJobStatus,
|
||||
InstallPhaseDetails, InstallPhaseId, InstallRequest, InstallTarget,
|
||||
};
|
||||
use super::store;
|
||||
use crate::event::InstancePayloadType;
|
||||
@@ -16,19 +16,41 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
|
||||
if job.state.display.is_none() {
|
||||
job.state.display = display_from_request(&job.state);
|
||||
}
|
||||
let interrupted_phase = job.state.progress.phase;
|
||||
job.state.record_event(InstallJobEventKind::Interrupted {
|
||||
reason: InstallInterruptReason::AppClosed,
|
||||
phase: interrupted_phase,
|
||||
});
|
||||
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(),
|
||||
});
|
||||
job.state.error = Some(InstallErrorView::from_message(
|
||||
"app_closed",
|
||||
interrupted_phase,
|
||||
"App closed while install was running",
|
||||
));
|
||||
|
||||
job.state
|
||||
.record_event(InstallJobEventKind::RollbackStarted {
|
||||
cleanup: job.state.cleanup.clone(),
|
||||
});
|
||||
if let Err(error) = apply_cleanup(&job.state, state).await {
|
||||
tracing::error!(
|
||||
"Error cleaning up interrupted install job {}: {error}",
|
||||
job.id
|
||||
);
|
||||
job.state.rollback_error = Some(InstallErrorView::from_error(
|
||||
"rollback_error",
|
||||
InstallPhaseId::RollingBack,
|
||||
&error,
|
||||
None,
|
||||
));
|
||||
job.state.record_event(InstallJobEventKind::RollbackFailed {
|
||||
message: error.to_string(),
|
||||
});
|
||||
} else {
|
||||
job.state
|
||||
.record_event(InstallJobEventKind::RollbackCompleted);
|
||||
}
|
||||
clear_deleted_new_instance_id(&mut job.state);
|
||||
|
||||
@@ -55,38 +77,40 @@ fn clear_deleted_new_instance_id(job_state: &mut InstallJobState) {
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::events::{InstallProgressReporter, emit_install_job};
|
||||
use super::model::{
|
||||
InstallCleanup, InstallErrorView, InstallJobDisplay, InstallJobSnapshot,
|
||||
InstallJobState, InstallJobStatus, InstallPhaseDetails, InstallPhaseId,
|
||||
InstallPostInstallEdit, InstallRequest, InstallRollbackState,
|
||||
InstallTarget,
|
||||
InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay,
|
||||
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
|
||||
InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit,
|
||||
InstallRequest, InstallRollbackState, InstallTarget,
|
||||
};
|
||||
use super::{recovery, store};
|
||||
use super::{diagnostics, recovery, store};
|
||||
use crate::ErrorKind;
|
||||
use crate::api::pack::install_from::{
|
||||
CreatePackLocation, generate_pack_from_file,
|
||||
@@ -108,6 +108,12 @@ pub async fn get_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
Ok(store::get_required(job_id, &state).await?.snapshot())
|
||||
}
|
||||
|
||||
pub async fn job_support_details(job_id: Uuid) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let job = store::get_required(job_id, &state).await?;
|
||||
diagnostics::build_job_support_details(&job, &state).await
|
||||
}
|
||||
|
||||
pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
let state = State::get().await?;
|
||||
let mut job = store::get_required(job_id, &state).await?;
|
||||
@@ -127,10 +133,15 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
job.state.cleanup = job.state.request.cleanup();
|
||||
job.state.rollback = None;
|
||||
job.state.error = None;
|
||||
job.state.rollback_error = None;
|
||||
job.state.context = None;
|
||||
job.state.progress.phase = InstallPhaseId::PreparingInstance;
|
||||
job.state.progress.progress = None;
|
||||
job.state.progress.details = InstallPhaseDetails::Empty;
|
||||
prepare_initial_instance(&mut job.state, &state).await?;
|
||||
job.state.record_event(InstallJobEventKind::JobQueued {
|
||||
kind: job.state.request.kind(),
|
||||
});
|
||||
|
||||
let record = store::update_status(
|
||||
job_id,
|
||||
@@ -156,11 +167,35 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
.into());
|
||||
}
|
||||
|
||||
job.state.error = Some(InstallErrorView {
|
||||
code: "canceled".to_string(),
|
||||
message: "Install was canceled".to_string(),
|
||||
let canceled_phase = job.state.progress.phase;
|
||||
job.state.error = Some(InstallErrorView::from_message(
|
||||
"canceled",
|
||||
canceled_phase,
|
||||
"Install was canceled",
|
||||
));
|
||||
job.state.record_event(InstallJobEventKind::JobCanceled {
|
||||
phase: canceled_phase,
|
||||
});
|
||||
recovery::apply_cleanup(&job.state, &state).await?;
|
||||
job.state
|
||||
.record_event(InstallJobEventKind::RollbackStarted {
|
||||
cleanup: job.state.cleanup.clone(),
|
||||
});
|
||||
match recovery::apply_cleanup(&job.state, &state).await {
|
||||
Ok(()) => job
|
||||
.state
|
||||
.record_event(InstallJobEventKind::RollbackCompleted),
|
||||
Err(error) => {
|
||||
job.state.rollback_error = Some(InstallErrorView::from_error(
|
||||
"rollback_error",
|
||||
InstallPhaseId::RollingBack,
|
||||
&error,
|
||||
None,
|
||||
));
|
||||
job.state.record_event(InstallJobEventKind::RollbackFailed {
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
clear_deleted_new_instance_id(&mut job.state);
|
||||
let record = store::update_status(
|
||||
job_id,
|
||||
@@ -328,13 +363,21 @@ fn spawn_job(job_id: Uuid) {
|
||||
|
||||
async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let job = store::get_required(job_id, &state).await?;
|
||||
let mut job = store::get_required(job_id, &state).await?;
|
||||
|
||||
if job.status != InstallJobStatus::Queued {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _install_permit = state.install_job_semaphore.acquire().await?;
|
||||
job = store::get_required(job_id, &state).await?;
|
||||
|
||||
if job.status != InstallJobStatus::Queued {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut job_state = job.state.clone();
|
||||
job_state.record_event(InstallJobEventKind::JobStarted);
|
||||
let record = store::update_status(
|
||||
job_id,
|
||||
InstallJobStatus::Running,
|
||||
@@ -345,16 +388,24 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
|
||||
let result = 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 {
|
||||
Ok(instance_id) => {
|
||||
if let Some(instance_id) = instance_id {
|
||||
set_instance_id(&mut job_state, instance_id);
|
||||
}
|
||||
job_state.record_event(InstallJobEventKind::JobSucceeded {
|
||||
instance_id: current_instance_id(&job_state),
|
||||
});
|
||||
job_state.progress.phase = InstallPhaseId::Finalizing;
|
||||
job_state.progress.progress = None;
|
||||
job_state.progress.details = InstallPhaseDetails::Empty;
|
||||
job_state.error = None;
|
||||
job_state.rollback_error = None;
|
||||
job_state.context = None;
|
||||
let record = store::update_status(
|
||||
job_id,
|
||||
InstallJobStatus::Succeeded,
|
||||
@@ -365,11 +416,41 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
let failed_phase = job_state.progress.phase;
|
||||
let error_view = install_error_view(
|
||||
failed_phase,
|
||||
&error,
|
||||
job_state.context.clone(),
|
||||
);
|
||||
job_state.record_event(InstallJobEventKind::Failed {
|
||||
phase: failed_phase,
|
||||
code: error_view.code.clone(),
|
||||
message: error_view.message.clone(),
|
||||
});
|
||||
job_state.error = Some(error_view);
|
||||
job_state.progress.phase = InstallPhaseId::RollingBack;
|
||||
job_state.progress.progress = None;
|
||||
job_state.progress.details = InstallPhaseDetails::Empty;
|
||||
job_state.error = Some(install_error_view(&error));
|
||||
recovery::apply_cleanup(&job_state, &state).await?;
|
||||
job_state.record_event(InstallJobEventKind::RollbackStarted {
|
||||
cleanup: job_state.cleanup.clone(),
|
||||
});
|
||||
if let Err(rollback_error) =
|
||||
recovery::apply_cleanup(&job_state, &state).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Error rolling back failed install job {job_id}: {rollback_error}"
|
||||
);
|
||||
job_state.rollback_error = Some(install_error_view(
|
||||
InstallPhaseId::RollingBack,
|
||||
&rollback_error,
|
||||
None,
|
||||
));
|
||||
job_state.record_event(InstallJobEventKind::RollbackFailed {
|
||||
message: rollback_error.to_string(),
|
||||
});
|
||||
} else {
|
||||
job_state.record_event(InstallJobEventKind::RollbackCompleted);
|
||||
}
|
||||
clear_deleted_new_instance_id(&mut job_state);
|
||||
let record = store::update_status(
|
||||
job_id,
|
||||
@@ -812,6 +893,14 @@ async fn install_pack(
|
||||
title,
|
||||
icon_url,
|
||||
} => {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("download modpack file")
|
||||
.project_id(project_id.clone())
|
||||
.version_id(version_id.clone())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
generate_pack_from_version_id_with_reporter(
|
||||
project_id,
|
||||
version_id,
|
||||
@@ -824,6 +913,13 @@ async fn install_pack(
|
||||
.await?
|
||||
}
|
||||
CreatePackLocation::FromFile { path } => {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("read local modpack file")
|
||||
.source_path(path.display().to_string())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
generate_pack_from_file(path, instance_id.clone()).await?
|
||||
}
|
||||
};
|
||||
@@ -887,9 +983,7 @@ async fn update_progress(
|
||||
phase: InstallPhaseId,
|
||||
details: InstallPhaseDetails,
|
||||
) -> crate::Result<()> {
|
||||
job_state.progress.phase = phase;
|
||||
job_state.progress.progress = None;
|
||||
job_state.progress.details = details;
|
||||
job_state.set_progress(phase, None, details);
|
||||
let record = store::update_state(job_id, job_state, state).await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
Ok(())
|
||||
@@ -934,19 +1028,86 @@ fn set_display(
|
||||
job_state.display = Some(InstallJobDisplay { title, icon });
|
||||
}
|
||||
|
||||
fn install_error_view(error: &crate::Error) -> InstallErrorView {
|
||||
fn install_error_view(
|
||||
phase: InstallPhaseId,
|
||||
error: &crate::Error,
|
||||
context: Option<InstallErrorContext>,
|
||||
) -> InstallErrorView {
|
||||
InstallErrorView::from_error(
|
||||
install_error_code(phase, error),
|
||||
phase,
|
||||
error,
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
fn install_error_code(
|
||||
phase: InstallPhaseId,
|
||||
error: &crate::Error,
|
||||
) -> &'static str {
|
||||
use InstallPhaseId::*;
|
||||
|
||||
match error.raw.as_ref() {
|
||||
ErrorKind::FetchError(_)
|
||||
| ErrorKind::ApiIsDownError(_)
|
||||
| ErrorKind::WSError(_)
|
||||
| ErrorKind::WSClosedError(_) => InstallErrorView {
|
||||
code: "network_error".to_string(),
|
||||
message: "network_error".to_string(),
|
||||
ErrorKind::InputError(_) => match phase {
|
||||
PreparingInstance | Finalizing => "instance_error",
|
||||
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
|
||||
"pack_error"
|
||||
}
|
||||
DownloadingContent => "content_error",
|
||||
ExtractingOverrides => "path_error",
|
||||
PreparingJava => "java_error",
|
||||
DownloadingMinecraft => "instance_error",
|
||||
RollingBack => "rollback_error",
|
||||
ResolvingMinecraft | ResolvingLoader | RunningLoaderProcessors => {
|
||||
"launcher_error"
|
||||
}
|
||||
},
|
||||
_ => InstallErrorView {
|
||||
code: "unknown_error".to_string(),
|
||||
message: "unknown_error".to_string(),
|
||||
ErrorKind::LauncherError(_) => match phase {
|
||||
RunningLoaderProcessors => "processor_error",
|
||||
PreparingJava => "java_error",
|
||||
ResolvingLoader => "loader_error",
|
||||
_ => "launcher_error",
|
||||
},
|
||||
ErrorKind::JREError(_) => "java_error",
|
||||
ErrorKind::NoValueFor(_) | ErrorKind::MetadataError(_) => match phase {
|
||||
ResolvingLoader => "loader_error",
|
||||
PreparingJava => "java_error",
|
||||
_ => "metadata_error",
|
||||
},
|
||||
ErrorKind::FetchError(_) | ErrorKind::ApiIsDownError(_) => {
|
||||
"network_error"
|
||||
}
|
||||
ErrorKind::Any(_)
|
||||
if matches!(
|
||||
phase,
|
||||
DownloadingPackFile
|
||||
| DownloadingContent
|
||||
| ResolvingMinecraft
|
||||
| ResolvingLoader
|
||||
| PreparingJava
|
||||
| DownloadingMinecraft
|
||||
) =>
|
||||
{
|
||||
"network_error"
|
||||
}
|
||||
ErrorKind::LabrinthError(_) => "api_error",
|
||||
ErrorKind::HashError(_, _) => "hash_error",
|
||||
ErrorKind::ZipError(_) => "archive_error",
|
||||
ErrorKind::DeserializationError(_) | ErrorKind::StripPrefixError(_) => {
|
||||
"path_error"
|
||||
}
|
||||
ErrorKind::FSError(_)
|
||||
| ErrorKind::IOError(_)
|
||||
| ErrorKind::StdIOError(_)
|
||||
| ErrorKind::UTFError(_) => "filesystem_error",
|
||||
ErrorKind::INIError(_) | ErrorKind::JSONError(_) => "parse_error",
|
||||
ErrorKind::Sqlx(_) | ErrorKind::SqlxMigrate(_) => "database_error",
|
||||
ErrorKind::JoinError(_)
|
||||
| ErrorKind::RecvError(_)
|
||||
| ErrorKind::AcquireError(_)
|
||||
| ErrorKind::EventError(_) => "internal_error",
|
||||
ErrorKind::OtherError(_) | ErrorKind::Any(_) => "internal_error",
|
||||
_ => "unknown_error",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ impl InstallJobRecord {
|
||||
details: self.state.progress.details.clone(),
|
||||
display: self.state.display.clone(),
|
||||
error: self.state.error.clone(),
|
||||
rollback_error: self.state.rollback_error.clone(),
|
||||
created: self.created,
|
||||
modified: self.modified,
|
||||
finished: self.finished,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Downloader for Minecraft data
|
||||
|
||||
use crate::install::{
|
||||
InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||
InstallErrorContext, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
||||
InstallProgressReporter,
|
||||
};
|
||||
use crate::instance::QuickPlayType;
|
||||
@@ -128,6 +128,17 @@ impl MinecraftDownloadProgress {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_context(
|
||||
&self,
|
||||
context: InstallErrorContext,
|
||||
) -> crate::Result<()> {
|
||||
self.reporter.set_transient_context(context).await
|
||||
}
|
||||
|
||||
async fn persist_failure_context(&self, context: InstallErrorContext) {
|
||||
self.reporter.persist_failure_context(context).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_minecraft_file(
|
||||
@@ -136,7 +147,16 @@ async fn fetch_minecraft_file(
|
||||
sha1: Option<&str>,
|
||||
expected_size: Option<u64>,
|
||||
progress: Option<MinecraftDownloadProgress>,
|
||||
context: InstallErrorContext,
|
||||
) -> crate::Result<bytes::Bytes> {
|
||||
let mut context = context;
|
||||
context.urls.push(url.to_string());
|
||||
context.expected_hash = sha1.map(str::to_string);
|
||||
context.expected_size = expected_size;
|
||||
if let Some(progress) = &progress {
|
||||
progress.set_context(context.clone()).await?;
|
||||
}
|
||||
|
||||
let Some(progress) = progress else {
|
||||
return fetch(url, sha1, None, None, &st.fetch_semaphore, &st.pool)
|
||||
.await;
|
||||
@@ -157,7 +177,7 @@ async fn fetch_minecraft_file(
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = fetch_advanced_with_progress(
|
||||
let bytes = match fetch_advanced_with_progress(
|
||||
Method::GET,
|
||||
url,
|
||||
sha1,
|
||||
@@ -170,7 +190,14 @@ async fn fetch_minecraft_file(
|
||||
&st.pool,
|
||||
Some(&mut progress_fn as &mut FetchProgressFn<'_>),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
progress.persist_failure_context(context).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(expected_size) = expected_size {
|
||||
let downloaded = last_downloaded.load(Ordering::Relaxed);
|
||||
@@ -432,6 +459,7 @@ pub async fn download_version_info(
|
||||
loader: Option<&LoaderVersion>,
|
||||
force: Option<bool>,
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
reporter: Option<&InstallProgressReporter>,
|
||||
) -> crate::Result<GameVersionInfo> {
|
||||
let version_id = loader
|
||||
.map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id));
|
||||
@@ -452,6 +480,19 @@ pub async fn download_version_info(
|
||||
&version.id,
|
||||
version.url
|
||||
);
|
||||
if let Some(reporter) = reporter {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new(
|
||||
"download Minecraft version metadata",
|
||||
)
|
||||
.minecraft_version(version.id.clone())
|
||||
.urls(vec![version.url.clone()])
|
||||
.target_path(path.display().to_string())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let mut info = fetch_json(
|
||||
Method::GET,
|
||||
&version.url,
|
||||
@@ -464,6 +505,19 @@ pub async fn download_version_info(
|
||||
.await?;
|
||||
|
||||
if let Some(loader) = loader {
|
||||
if let Some(reporter) = reporter {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new(
|
||||
"download loader version metadata",
|
||||
)
|
||||
.minecraft_version(version.id.clone())
|
||||
.urls(vec![loader.url.clone()])
|
||||
.target_path(path.display().to_string())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let partial: d::modded::PartialVersionInfo = fetch_json(
|
||||
Method::GET,
|
||||
&loader.url,
|
||||
@@ -523,6 +577,11 @@ pub async fn download_client(
|
||||
Some(&client_download.sha1),
|
||||
Some(client_download.size as u64),
|
||||
progress,
|
||||
InstallErrorContext::new("download Minecraft client")
|
||||
.minecraft_version(version.to_string())
|
||||
.file_path(format!("{version}.jar"))
|
||||
.target_path(path.display().to_string())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
@@ -563,6 +622,11 @@ pub async fn download_assets_index(
|
||||
None,
|
||||
Some(version.asset_index.size as u64),
|
||||
progress,
|
||||
InstallErrorContext::new("download Minecraft assets index")
|
||||
.minecraft_version(version.id.clone())
|
||||
.file_path(format!("{}.json", version.asset_index.id))
|
||||
.target_path(path.display().to_string())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
let index = serde_json::from_slice(&index)?;
|
||||
@@ -632,6 +696,10 @@ pub async fn download_assets(
|
||||
Some(hash),
|
||||
Some(asset.size as u64),
|
||||
fetch_progress.clone(),
|
||||
InstallErrorContext::new("download Minecraft asset")
|
||||
.file_path(name.clone())
|
||||
.target_path(resource_path.display().to_string())
|
||||
.build(),
|
||||
))
|
||||
.await?;
|
||||
write(&resource_path, resource, &st.io_semaphore).await?;
|
||||
@@ -648,6 +716,10 @@ pub async fn download_assets(
|
||||
Some(hash),
|
||||
Some(asset.size as u64),
|
||||
fetch_progress.clone(),
|
||||
InstallErrorContext::new("download Minecraft asset")
|
||||
.file_path(name.clone())
|
||||
.target_path(legacy_resource_path.display().to_string())
|
||||
.build(),
|
||||
))
|
||||
.await?;
|
||||
write(&legacy_resource_path, resource, &st.io_semaphore).await?;
|
||||
@@ -729,6 +801,16 @@ pub async fn download_libraries(
|
||||
Some(&native.sha1),
|
||||
Some(native.size as u64),
|
||||
progress.clone(),
|
||||
InstallErrorContext::new("download Minecraft native library")
|
||||
.minecraft_version(version.to_string())
|
||||
.file_path(library.name.clone())
|
||||
.target_path(
|
||||
st.directories
|
||||
.version_natives_dir(version)
|
||||
.display()
|
||||
.to_string(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -774,6 +856,11 @@ pub async fn download_libraries(
|
||||
Some(&artifact.sha1),
|
||||
Some(artifact.size as u64),
|
||||
progress.clone(),
|
||||
InstallErrorContext::new("download Minecraft library")
|
||||
.minecraft_version(version.to_string())
|
||||
.file_path(library.name.clone())
|
||||
.target_path(path.display().to_string())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
@@ -880,6 +967,11 @@ pub async fn download_log_config(
|
||||
Some(&log_download.sha1),
|
||||
Some(log_download.size as u64),
|
||||
progress,
|
||||
InstallErrorContext::new("download Minecraft log config")
|
||||
.minecraft_version(version_info.id.clone())
|
||||
.file_path(log_download.id.clone())
|
||||
.target_path(path.display().to_string())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
|
||||
@@ -367,6 +367,7 @@ pub async fn install_minecraft_with_reporter(
|
||||
loader_version.as_ref(),
|
||||
Some(repairing),
|
||||
loading_bar.as_ref(),
|
||||
reporter.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -767,6 +768,7 @@ pub async fn launch_minecraft(
|
||||
loader_version.as_ref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if version_info.logging.is_none() {
|
||||
@@ -783,6 +785,7 @@ pub async fn launch_minecraft(
|
||||
loader_version.as_ref(),
|
||||
Some(true),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ pub mod server_join_log;
|
||||
// Global state
|
||||
// RwLock on state only has concurrent reads, except for config dir change which takes control of the State
|
||||
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
|
||||
const MAX_CONCURRENT_INSTALL_JOBS: usize = 3;
|
||||
pub struct State {
|
||||
/// Information on the location of files used in the launcher
|
||||
pub directories: DirectoryInfo,
|
||||
@@ -68,6 +69,8 @@ pub struct State {
|
||||
/// Semaphore to limit concurrent API requests. This is separate from the fetch semaphore
|
||||
/// to keep API functionality while the app is performing intensive tasks.
|
||||
pub api_semaphore: FetchSemaphore,
|
||||
pub(crate) install_job_semaphore: Semaphore,
|
||||
pub(crate) install_db_semaphore: Semaphore,
|
||||
|
||||
/// Discord RPC
|
||||
pub discord_rpc: DiscordGuard,
|
||||
@@ -205,6 +208,8 @@ impl State {
|
||||
fetch_semaphore,
|
||||
io_semaphore,
|
||||
api_semaphore,
|
||||
install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS),
|
||||
install_db_semaphore: Semaphore::new(1),
|
||||
discord_rpc,
|
||||
process_manager,
|
||||
friends_socket,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Functions for fetching information from the Internet
|
||||
use super::io::{self, IOError};
|
||||
use crate::ErrorKind;
|
||||
use crate::event::LoadingBarId;
|
||||
use crate::event::emit::emit_loading;
|
||||
use crate::{ErrorKind, LabrinthError};
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use eyre::{Context, eyre};
|
||||
@@ -190,6 +190,8 @@ static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
|
||||
|
||||
fn reqwest_client_builder() -> reqwest::ClientBuilder {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(time::Duration::from_secs(15))
|
||||
.read_timeout(time::Duration::from_secs(30))
|
||||
.tcp_keepalive(Some(time::Duration::from_secs(10)))
|
||||
.user_agent(crate::launcher_user_agent())
|
||||
}
|
||||
@@ -267,6 +269,34 @@ pub async fn fetch_with_client(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(semaphore, progress))]
|
||||
pub async fn fetch_with_client_progress(
|
||||
url: &str,
|
||||
sha1: Option<&str>,
|
||||
download_meta: Option<&DownloadMeta>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
client: &reqwest::Client,
|
||||
progress: Option<&mut FetchProgressFn<'_>>,
|
||||
) -> crate::Result<Bytes> {
|
||||
fetch_advanced_with_client_and_progress(
|
||||
Method::GET,
|
||||
url,
|
||||
sha1,
|
||||
None,
|
||||
None,
|
||||
download_meta,
|
||||
None,
|
||||
uri_path,
|
||||
semaphore,
|
||||
exec,
|
||||
client,
|
||||
progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(json_body, semaphore))]
|
||||
pub async fn fetch_json<T>(
|
||||
method: Method,
|
||||
@@ -466,8 +496,13 @@ async fn fetch_advanced_with_client_and_progress(
|
||||
if resp.status().is_client_error()
|
||||
|| resp.status().is_server_error()
|
||||
{
|
||||
let status = resp.status();
|
||||
let backup_error = resp.error_for_status_ref().unwrap_err();
|
||||
if let Ok(error) = resp.json().await {
|
||||
if let Ok(mut error) = resp.json::<LabrinthError>().await {
|
||||
error.status = Some(status.as_u16());
|
||||
error.method = Some(method.as_str().to_string());
|
||||
error.url = Some(url.to_string());
|
||||
error.route = uri_path.map(str::to_string);
|
||||
return Err(ErrorKind::LabrinthError(error).into());
|
||||
}
|
||||
return Err(backup_error.into());
|
||||
@@ -599,6 +634,43 @@ pub async fn fetch_mirrors(
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(semaphore, progress))]
|
||||
pub async fn fetch_mirrors_with_progress(
|
||||
mirrors: &[&str],
|
||||
sha1: Option<&str>,
|
||||
download_meta: Option<&DownloadMeta>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
mut progress: Option<&mut FetchProgressFn<'_>>,
|
||||
) -> crate::Result<Bytes> {
|
||||
if mirrors.is_empty() {
|
||||
return Err(
|
||||
ErrorKind::InputError("No mirrors provided!".to_string()).into()
|
||||
);
|
||||
}
|
||||
|
||||
for (index, mirror) in mirrors.iter().enumerate() {
|
||||
let result = fetch_with_client_progress(
|
||||
mirror,
|
||||
sha1,
|
||||
download_meta,
|
||||
uri_path,
|
||||
semaphore,
|
||||
exec,
|
||||
&REQWEST_CLIENT,
|
||||
progress.as_deref_mut(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Posts a JSON to a URL
|
||||
#[tracing::instrument(skip(json_body, semaphore))]
|
||||
pub async fn post_json(
|
||||
|
||||
Reference in New Issue
Block a user