Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2919 lines
101 KiB
Rust
2919 lines
101 KiB
Rust
use crate::api::curseforge::{
|
|
CurseForgeInstallRequest, CurseForgeWorldInstallRequest,
|
|
};
|
|
use crate::api::pack::import::ImportLauncherType;
|
|
use crate::api::pack::install_from::{CreatePackInstance, CreatePackLocation};
|
|
use crate::state::{
|
|
ContentDependencyEdge, ContentEntry, ContentProvider, ContentProviderRef,
|
|
ContentUpdateCheck, InstanceFile, InstanceInstallStage, InstanceLink,
|
|
InstanceMetadata, InstanceUpgradeAction, InstanceUpgradeEnvironment,
|
|
InstanceUpgradeIssue, InstanceUpgradeIssueCode, InstanceUpgradeItem,
|
|
InstanceUpgradeSolution, InstanceUpgradeSourceFile, ModLoader, PackMember,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use modrinth_content_management::{ContentType, ResolutionPreferences};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
path::PathBuf,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
pub type InstallModpackPreview = CreatePackInstance;
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum InstallPauseReason {
|
|
MissingRequiredContent {
|
|
failed_files: u64,
|
|
paths: Vec<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum InstallContinuationState {
|
|
InstallingPackToExistingInstance { disabled_project_ids: Vec<String> },
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct InstallJobState {
|
|
pub schema_version: u32,
|
|
pub request: InstallRequest,
|
|
pub target: InstallTarget,
|
|
pub cleanup: InstallCleanup,
|
|
pub progress: InstallProgressState,
|
|
pub paths: InstallJobPaths,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub context: Option<InstallErrorContext>,
|
|
#[serde(default)]
|
|
pub events: Vec<InstallJobEvent>,
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
pub active_downloads: HashMap<String, ActiveDownloadState>,
|
|
#[serde(default)]
|
|
pub display: Option<InstallJobDisplay>,
|
|
pub rollback: Option<InstallRollbackState>,
|
|
pub error: Option<InstallErrorView>,
|
|
#[serde(default)]
|
|
pub rollback_error: Option<InstallErrorView>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub pause_reason: Option<InstallPauseReason>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub continuation: Option<InstallContinuationState>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub missing_content: Option<MissingModpackContentState>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub skipped_missing_content_paths: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub upgrade_result: Option<InstanceUpgradeResult>,
|
|
/// Incrementally maintained download items, mirroring the replay of
|
|
/// `events`. Skipped during (de)serialization because it is derivable
|
|
/// from `events`; rebuilt lazily on first access after a load.
|
|
#[serde(skip)]
|
|
download_items_cache: std::sync::Mutex<Option<Vec<DownloadItemSnapshot>>>,
|
|
#[serde(skip)]
|
|
summary_cache: std::sync::Mutex<Option<DownloadJobSummary>>,
|
|
}
|
|
|
|
impl Clone for InstallJobState {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
schema_version: self.schema_version,
|
|
request: self.request.clone(),
|
|
target: self.target.clone(),
|
|
cleanup: self.cleanup.clone(),
|
|
progress: self.progress.clone(),
|
|
paths: self.paths.clone(),
|
|
context: self.context.clone(),
|
|
events: self.events.clone(),
|
|
active_downloads: self.active_downloads.clone(),
|
|
display: self.display.clone(),
|
|
rollback: self.rollback.clone(),
|
|
error: self.error.clone(),
|
|
rollback_error: self.rollback_error.clone(),
|
|
pause_reason: self.pause_reason.clone(),
|
|
continuation: self.continuation.clone(),
|
|
missing_content: self.missing_content.clone(),
|
|
skipped_missing_content_paths: self
|
|
.skipped_missing_content_paths
|
|
.clone(),
|
|
upgrade_result: self.upgrade_result.clone(),
|
|
download_items_cache: std::sync::Mutex::new(None),
|
|
summary_cache: std::sync::Mutex::new(None),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl InstallJobState {
|
|
pub fn new(request: InstallRequest) -> Self {
|
|
let target = request.target();
|
|
let cleanup = request.cleanup();
|
|
let kind = request.kind();
|
|
// Content installs do not create or prepare an instance. Start them in
|
|
// the content phase so the download center never presents them as an
|
|
// instance setup task while the worker is being scheduled.
|
|
let phase = initial_phase_for_request(&request);
|
|
|
|
Self {
|
|
schema_version: 1,
|
|
request,
|
|
target,
|
|
cleanup,
|
|
progress: InstallProgressState {
|
|
phase,
|
|
progress: None,
|
|
details: InstallPhaseDetails::Empty,
|
|
parallel: None,
|
|
},
|
|
paths: InstallJobPaths::default(),
|
|
context: None,
|
|
events: vec![InstallJobEvent {
|
|
at: Utc::now(),
|
|
kind: InstallJobEventKind::JobQueued { kind },
|
|
}],
|
|
active_downloads: HashMap::new(),
|
|
display: None,
|
|
rollback: None,
|
|
error: None,
|
|
rollback_error: None,
|
|
pause_reason: None,
|
|
continuation: None,
|
|
missing_content: None,
|
|
skipped_missing_content_paths: Vec::new(),
|
|
upgrade_result: None,
|
|
download_items_cache: std::sync::Mutex::new(None),
|
|
summary_cache: std::sync::Mutex::new(None),
|
|
}
|
|
}
|
|
|
|
/// Invalidates the lazily built download item and summary caches.
|
|
pub(crate) fn invalidate_download_caches(&mut self) {
|
|
*self.download_items_cache.lock().unwrap() = None;
|
|
*self.summary_cache.lock().unwrap() = None;
|
|
}
|
|
|
|
pub fn record_event(&mut self, kind: InstallJobEventKind) {
|
|
if matches!(
|
|
&kind,
|
|
InstallJobEventKind::JobStarted
|
|
| InstallJobEventKind::JobSucceeded { .. }
|
|
| InstallJobEventKind::JobCanceled { .. }
|
|
| InstallJobEventKind::WaitingForUser { .. }
|
|
) {
|
|
self.active_downloads.clear();
|
|
}
|
|
self.events.push(InstallJobEvent {
|
|
at: Utc::now(),
|
|
kind,
|
|
});
|
|
self.invalidate_download_caches();
|
|
}
|
|
|
|
pub fn compact_transient_download_events(&mut self) {
|
|
self.events.retain(|event| {
|
|
!matches!(
|
|
event.kind,
|
|
InstallJobEventKind::DownloadRequestStarted { .. }
|
|
| InstallJobEventKind::DownloadRequestFinished { .. }
|
|
| InstallJobEventKind::DownloadRequestFailed { .. }
|
|
)
|
|
});
|
|
self.invalidate_download_caches();
|
|
}
|
|
|
|
pub fn set_context(&mut self, context: Option<InstallErrorContext>) {
|
|
self.context = context;
|
|
}
|
|
|
|
pub fn set_progress(
|
|
&mut self,
|
|
phase: InstallPhaseId,
|
|
mut progress: Option<InstallProgress>,
|
|
details: InstallPhaseDetails,
|
|
) {
|
|
let same_phase = self.progress.phase == phase;
|
|
if !same_phase
|
|
|| matches!(&self.progress.details, InstallPhaseDetails::Empty)
|
|
&& !matches!(&details, InstallPhaseDetails::Empty)
|
|
{
|
|
self.record_event(InstallJobEventKind::PhaseStarted {
|
|
phase,
|
|
details: details.clone(),
|
|
});
|
|
}
|
|
|
|
if same_phase
|
|
&& let Some(new_progress) = &mut progress
|
|
&& let Some(old_progress) = &self.progress.progress
|
|
&& let (Some(old_secondary), Some(new_secondary)) =
|
|
(&old_progress.secondary, &new_progress.secondary)
|
|
&& old_secondary.total == new_secondary.total
|
|
{
|
|
new_progress.secondary = Some(InstallProgressSecondary {
|
|
current: old_secondary.current.max(new_secondary.current),
|
|
total: new_secondary.total,
|
|
});
|
|
}
|
|
self.progress.phase = phase;
|
|
self.progress.progress = progress;
|
|
self.progress.details = details;
|
|
}
|
|
}
|
|
|
|
pub(crate) fn initial_phase_for_request(
|
|
request: &InstallRequest,
|
|
) -> InstallPhaseId {
|
|
match request {
|
|
InstallRequest::InstallContent { .. }
|
|
| InstallRequest::InstallCurseForgeContent { .. }
|
|
| InstallRequest::InstallCurseForgeWorld { .. } => {
|
|
InstallPhaseId::DownloadingContent
|
|
}
|
|
_ => InstallPhaseId::PreparingInstance,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::state::{
|
|
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
|
|
InstanceLaunchOverrides, LauncherFeatureVersion, ReleaseChannel,
|
|
};
|
|
use chrono::TimeDelta;
|
|
|
|
fn job_state() -> InstallJobState {
|
|
InstallJobState::new(InstallRequest::CreateInstance {
|
|
name: "Test".to_string(),
|
|
game_version: "1.21.1".to_string(),
|
|
loader: ModLoader::Vanilla,
|
|
loader_version: None,
|
|
adjuncts: Vec::new(),
|
|
icon_path: None,
|
|
link: InstanceLink::Unmanaged,
|
|
game_dir_override: None,
|
|
})
|
|
}
|
|
|
|
fn set_secondary_progress(
|
|
job: &mut InstallJobState,
|
|
phase: InstallPhaseId,
|
|
current: u64,
|
|
total: u64,
|
|
) {
|
|
job.set_progress(
|
|
phase,
|
|
Some(InstallProgress {
|
|
current: 0,
|
|
total: 1,
|
|
secondary: Some(InstallProgressSecondary { current, total }),
|
|
}),
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
}
|
|
|
|
fn secondary_progress(job: &InstallJobState) -> (u64, u64) {
|
|
let secondary = job
|
|
.progress
|
|
.progress
|
|
.as_ref()
|
|
.and_then(|progress| progress.secondary.as_ref())
|
|
.unwrap();
|
|
(secondary.current, secondary.total)
|
|
}
|
|
|
|
#[test]
|
|
fn same_phase_same_secondary_total_keeps_progress_monotonic() {
|
|
let mut job = job_state();
|
|
set_secondary_progress(
|
|
&mut job,
|
|
InstallPhaseId::DownloadingContent,
|
|
268,
|
|
300,
|
|
);
|
|
set_secondary_progress(
|
|
&mut job,
|
|
InstallPhaseId::DownloadingContent,
|
|
200,
|
|
300,
|
|
);
|
|
|
|
assert_eq!(secondary_progress(&job), (268, 300));
|
|
}
|
|
|
|
#[test]
|
|
fn different_phase_does_not_retain_secondary_progress() {
|
|
let mut job = job_state();
|
|
set_secondary_progress(
|
|
&mut job,
|
|
InstallPhaseId::DownloadingContent,
|
|
268,
|
|
300,
|
|
);
|
|
set_secondary_progress(
|
|
&mut job,
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
0,
|
|
16,
|
|
);
|
|
|
|
assert_eq!(secondary_progress(&job), (0, 16));
|
|
}
|
|
|
|
#[test]
|
|
fn same_phase_different_secondary_total_starts_new_counter() {
|
|
let mut job = job_state();
|
|
set_secondary_progress(
|
|
&mut job,
|
|
InstallPhaseId::DownloadingContent,
|
|
268,
|
|
300,
|
|
);
|
|
set_secondary_progress(
|
|
&mut job,
|
|
InstallPhaseId::DownloadingContent,
|
|
0,
|
|
16,
|
|
);
|
|
|
|
assert_eq!(secondary_progress(&job), (0, 16));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_rollback_state_defaults_missing_content_revision() {
|
|
let now = Utc::now();
|
|
let instance_id = "instance".to_string();
|
|
let content_set_id = "content-set".to_string();
|
|
let mut job =
|
|
InstallJobState::new(InstallRequest::InstallExistingInstance {
|
|
instance_id: instance_id.clone(),
|
|
force: false,
|
|
});
|
|
job.rollback = Some(InstallRollbackState {
|
|
instance: InstanceMetadata {
|
|
instance: Instance {
|
|
id: instance_id.clone(),
|
|
path: "instance".to_string(),
|
|
applied_content_set_id: Some(content_set_id.clone()),
|
|
install_stage: InstanceInstallStage::Installed,
|
|
launcher_feature_version:
|
|
LauncherFeatureVersion::MOST_RECENT,
|
|
update_channel: ReleaseChannel::Release,
|
|
name: "Test".to_string(),
|
|
icon_path: None,
|
|
symlink_target: None,
|
|
linked_launcher: None,
|
|
linked_launcher_root: None,
|
|
linked_dot_minecraft: None,
|
|
linked_version_id: None,
|
|
linked_version_json_path: None,
|
|
linked_game_dir_mode: None,
|
|
game_dir_override: None,
|
|
created: now,
|
|
modified: now,
|
|
last_played: None,
|
|
pinned_at: None,
|
|
submitted_time_played: 0,
|
|
recent_time_played: 0,
|
|
},
|
|
applied_content_set: ContentSet {
|
|
id: content_set_id,
|
|
instance_id: instance_id.clone(),
|
|
name: "Test".to_string(),
|
|
source_kind: ContentSourceKind::Local,
|
|
status: ContentSetStatus::Available,
|
|
game_version: "1.21.1".to_string(),
|
|
protocol_version: None,
|
|
loader: ModLoader::Vanilla,
|
|
loader_version: None,
|
|
revision: 7,
|
|
created: now,
|
|
modified: now,
|
|
},
|
|
link: InstanceLink::Unmanaged,
|
|
groups: Vec::new(),
|
|
launch_overrides: InstanceLaunchOverrides::empty(instance_id),
|
|
loader_components: Vec::new(),
|
|
},
|
|
install_stage: InstanceInstallStage::Installed,
|
|
content: None,
|
|
});
|
|
|
|
let mut legacy = serde_json::to_value(job).unwrap();
|
|
legacy["rollback"]["instance"]["applied_content_set"]
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.remove("revision");
|
|
|
|
let restored: InstallJobState = serde_json::from_value(legacy).unwrap();
|
|
|
|
assert_eq!(
|
|
restored
|
|
.rollback
|
|
.unwrap()
|
|
.instance
|
|
.applied_content_set
|
|
.revision,
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn download_summary_uses_content_events_and_live_progress() {
|
|
let mut job = job_state();
|
|
job.record_event(InstallJobEventKind::ContentDownloadStarted {
|
|
files: 3,
|
|
bytes: Some(300),
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileDownloadAttempt {
|
|
path: "mods/a.jar".to_string(),
|
|
bytes_total: Some(100),
|
|
attempt: 2,
|
|
max_attempts: 3,
|
|
});
|
|
job.record_event(InstallJobEventKind::DownloadRequestStarted {
|
|
path: "mods/a.jar".to_string(),
|
|
name: "a.jar".to_string(),
|
|
url: "https://mod.mcimirror.top/data/a.jar".to_string(),
|
|
source: "mcim".to_string(),
|
|
bytes_total: Some(100),
|
|
attempt: 1,
|
|
max_attempts: 4,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileCompleted {
|
|
path: "mods/a.jar".to_string(),
|
|
bytes: 100,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileDownloadAttempt {
|
|
path: "mods/manual.jar".to_string(),
|
|
bytes_total: Some(120),
|
|
attempt: 3,
|
|
max_attempts: 3,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileSkipped {
|
|
path: "mods/manual.jar".to_string(),
|
|
reason: "manual download required".to_string(),
|
|
project_id: Some("123".to_string()),
|
|
version_id: Some("456".to_string()),
|
|
manual_url: Some(
|
|
"https://www.curseforge.com/minecraft/mc-mods/example"
|
|
.to_string(),
|
|
),
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileFailed {
|
|
path: "mods/failed.jar".to_string(),
|
|
reason: "network request failed".to_string(),
|
|
project_id: Some("789".to_string()),
|
|
version_id: Some("987".to_string()),
|
|
});
|
|
job.set_progress(
|
|
InstallPhaseId::DownloadingContent,
|
|
Some(InstallProgress {
|
|
current: 3,
|
|
total: 3,
|
|
secondary: Some(InstallProgressSecondary {
|
|
current: 220,
|
|
total: 300,
|
|
}),
|
|
}),
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
|
|
let summary = job.download_summary();
|
|
assert_eq!(summary.files_completed, 3);
|
|
assert_eq!(summary.files_total, Some(3));
|
|
assert_eq!(summary.bytes_downloaded, 220);
|
|
assert_eq!(summary.bytes_total, Some(300));
|
|
let items = job.download_items();
|
|
assert_eq!(items.len(), 3);
|
|
assert_eq!(items[0].status, DownloadItemStatus::Completed);
|
|
assert_eq!(items[0].attempt, Some(1));
|
|
assert_eq!(items[0].max_attempts, Some(4));
|
|
assert_eq!(
|
|
items[0].request_url.as_deref(),
|
|
Some("https://mod.mcimirror.top/data/a.jar")
|
|
);
|
|
assert_eq!(items[0].source.as_deref(), Some("mcim"));
|
|
assert_eq!(items[1].status, DownloadItemStatus::Skipped);
|
|
assert_eq!(items[1].attempt, Some(3));
|
|
assert_eq!(items[1].max_attempts, Some(3));
|
|
assert_eq!(items[1].project_id.as_deref(), Some("123"));
|
|
assert_eq!(items[1].version_id.as_deref(), Some("456"));
|
|
assert!(items[1].manual_url.is_some());
|
|
assert_eq!(items[2].status, DownloadItemStatus::Failed);
|
|
assert_eq!(items[2].project_id.as_deref(), Some("789"));
|
|
assert_eq!(items[2].version_id.as_deref(), Some("987"));
|
|
assert!(items[2].manual_url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn required_content_items_are_queued_before_download_requests_start() {
|
|
let mut job = job_state();
|
|
job.record_event(InstallJobEventKind::ContentDownloadStarted {
|
|
files: 2,
|
|
bytes: Some(300),
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileQueued {
|
|
path: "mods/a.jar".to_string(),
|
|
bytes_total: Some(100),
|
|
max_attempts: 4,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileQueued {
|
|
path: "mods/b.jar".to_string(),
|
|
bytes_total: Some(200),
|
|
max_attempts: 6,
|
|
});
|
|
|
|
let items = job.download_items();
|
|
assert_eq!(items.len(), 2);
|
|
assert!(
|
|
items
|
|
.iter()
|
|
.all(|item| item.status == DownloadItemStatus::Queued)
|
|
);
|
|
assert!(items.iter().all(|item| item.attempt == Some(0)));
|
|
assert_eq!(items[0].max_attempts, Some(4));
|
|
assert_eq!(items[1].bytes_total, Some(200));
|
|
assert!(items.iter().all(|item| item.request_url.is_none()));
|
|
assert!(items.iter().all(|item| item.source.is_none()));
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_validation_mode_is_derived_from_typed_resume_history() {
|
|
let mut job =
|
|
InstallJobState::new(InstallRequest::CreateModpackInstance {
|
|
location: CreatePackLocation::FromFile {
|
|
path: PathBuf::from("test.mrpack"),
|
|
},
|
|
post_install_edit: None,
|
|
});
|
|
job.missing_content = Some(MissingModpackContentState::default());
|
|
job.set_progress(
|
|
InstallPhaseId::DownloadingContent,
|
|
None,
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
assert_eq!(
|
|
job.execution_mode(InstallJobStatus::Running),
|
|
InstallJobExecutionMode::Normal
|
|
);
|
|
|
|
job.record_event(InstallJobEventKind::WaitingForUser {
|
|
reason: InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec!["mods/missing.jar".to_string()],
|
|
},
|
|
});
|
|
assert_eq!(
|
|
job.execution_mode(InstallJobStatus::WaitingForUser),
|
|
InstallJobExecutionMode::Normal
|
|
);
|
|
job.record_event(InstallJobEventKind::JobQueued {
|
|
kind: InstallJobKind::CreateModpackInstance,
|
|
});
|
|
assert_eq!(
|
|
job.execution_mode(InstallJobStatus::Queued),
|
|
InstallJobExecutionMode::RecoveryValidation
|
|
);
|
|
assert_eq!(
|
|
job.execution_mode(InstallJobStatus::Running),
|
|
InstallJobExecutionMode::RecoveryValidation
|
|
);
|
|
|
|
job.set_progress(
|
|
InstallPhaseId::ExtractingOverrides,
|
|
None,
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
assert_eq!(
|
|
job.execution_mode(InstallJobStatus::Running),
|
|
InstallJobExecutionMode::Normal
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resumed_existing_file_moves_from_queued_to_verifying() {
|
|
let mut job =
|
|
InstallJobState::new(InstallRequest::CreateModpackInstance {
|
|
location: CreatePackLocation::FromFile {
|
|
path: PathBuf::from("test.mrpack"),
|
|
},
|
|
post_install_edit: None,
|
|
});
|
|
let path = "mods/missing.jar".to_string();
|
|
job.missing_content = Some(MissingModpackContentState::default());
|
|
job.set_progress(
|
|
InstallPhaseId::DownloadingContent,
|
|
None,
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
job.record_event(InstallJobEventKind::ContentFileQueued {
|
|
path: path.clone(),
|
|
bytes_total: Some(100),
|
|
max_attempts: 4,
|
|
});
|
|
job.record_event(InstallJobEventKind::WaitingForUser {
|
|
reason: InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec![path.clone()],
|
|
},
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileRecovered {
|
|
path: path.clone(),
|
|
bytes: 100,
|
|
});
|
|
job.record_event(InstallJobEventKind::JobQueued {
|
|
kind: InstallJobKind::CreateModpackInstance,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileQueued {
|
|
path: path.clone(),
|
|
bytes_total: Some(100),
|
|
max_attempts: 4,
|
|
});
|
|
assert_eq!(job.download_items()[0].status, DownloadItemStatus::Queued);
|
|
|
|
job.record_event(InstallJobEventKind::ContentFileVerificationStarted {
|
|
path,
|
|
});
|
|
assert_eq!(
|
|
job.execution_mode(InstallJobStatus::Running),
|
|
InstallJobExecutionMode::RecoveryValidation
|
|
);
|
|
assert_eq!(
|
|
job.download_items()[0].status,
|
|
DownloadItemStatus::Verifying
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn all_successful_required_content_items_are_completed() {
|
|
let mut job = job_state();
|
|
for (path, bytes) in [("mods/a.jar", 100), ("mods/b.jar", 200)] {
|
|
job.record_event(InstallJobEventKind::ContentFileQueued {
|
|
path: path.to_string(),
|
|
bytes_total: Some(bytes),
|
|
max_attempts: 4,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileCompleted {
|
|
path: path.to_string(),
|
|
bytes,
|
|
});
|
|
}
|
|
|
|
let items = job.download_items();
|
|
assert_eq!(items.len(), 2);
|
|
assert!(
|
|
items
|
|
.iter()
|
|
.all(|item| item.status == DownloadItemStatus::Completed)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn skipped_required_content_item_is_not_failed() {
|
|
let mut job = job_state();
|
|
job.record_event(InstallJobEventKind::ContentFileQueued {
|
|
path: "mods/client-unsupported.jar".to_string(),
|
|
bytes_total: Some(100),
|
|
max_attempts: 2,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileSkipped {
|
|
path: "mods/client-unsupported.jar".to_string(),
|
|
reason: "unsupported on client".to_string(),
|
|
project_id: None,
|
|
version_id: None,
|
|
manual_url: None,
|
|
});
|
|
|
|
let items = job.download_items();
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].status, DownloadItemStatus::Skipped);
|
|
assert_ne!(items[0].status, DownloadItemStatus::Failed);
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_required_content_failures_have_individual_failed_items() {
|
|
let mut job = job_state();
|
|
for path in ["mods/a.jar", "mods/b.jar", "mods/c.jar"] {
|
|
job.record_event(InstallJobEventKind::ContentFileQueued {
|
|
path: path.to_string(),
|
|
bytes_total: Some(100),
|
|
max_attempts: 4,
|
|
});
|
|
}
|
|
for path in ["mods/a.jar", "mods/c.jar"] {
|
|
job.record_event(InstallJobEventKind::ContentFileFailed {
|
|
path: path.to_string(),
|
|
reason: "download failed".to_string(),
|
|
project_id: None,
|
|
version_id: None,
|
|
});
|
|
}
|
|
job.record_event(InstallJobEventKind::ContentFileCompleted {
|
|
path: "mods/b.jar".to_string(),
|
|
bytes: 100,
|
|
});
|
|
|
|
let items = job.download_items();
|
|
assert_eq!(items.len(), 3);
|
|
assert_eq!(
|
|
items
|
|
.iter()
|
|
.filter(|item| item.status == DownloadItemStatus::Failed)
|
|
.count(),
|
|
2
|
|
);
|
|
assert_eq!(items[1].status, DownloadItemStatus::Completed);
|
|
}
|
|
|
|
#[test]
|
|
fn request_events_create_items_for_java_and_minecraft_downloads() {
|
|
let mut job = job_state();
|
|
job.record_event(InstallJobEventKind::DownloadRequestStarted {
|
|
path: "java/runtime-manifest.json".to_string(),
|
|
name: "runtime-manifest.json".to_string(),
|
|
url: "https://piston-meta.mojang.com/v1/runtime.json".to_string(),
|
|
source: "official".to_string(),
|
|
bytes_total: Some(1024),
|
|
attempt: 1,
|
|
max_attempts: 2,
|
|
});
|
|
job.record_event(InstallJobEventKind::DownloadRequestFinished {
|
|
path: "java/runtime-manifest.json".to_string(),
|
|
bytes: 1024,
|
|
});
|
|
|
|
let items = job.download_items();
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].name, "runtime-manifest.json");
|
|
assert_eq!(items[0].status, DownloadItemStatus::Completed);
|
|
assert_eq!(items[0].bytes_downloaded, 1024);
|
|
assert_eq!(items[0].bytes_total, Some(1024));
|
|
assert_eq!(items[0].source.as_deref(), Some("official"));
|
|
|
|
job.compact_transient_download_events();
|
|
assert!(job.events.iter().all(|event| !matches!(
|
|
event.kind,
|
|
InstallJobEventKind::DownloadRequestStarted { .. }
|
|
| InstallJobEventKind::DownloadRequestFinished { .. }
|
|
| InstallJobEventKind::DownloadRequestFailed { .. }
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn active_downloads_expose_live_bytes_and_hide_stale_eta() {
|
|
let mut job = job_state();
|
|
job.set_progress(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
Some(InstallProgress {
|
|
current: 400,
|
|
total: 1_000,
|
|
secondary: None,
|
|
}),
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
job.active_downloads.insert(
|
|
"client.jar".to_string(),
|
|
ActiveDownloadState {
|
|
name: "client.jar".to_string(),
|
|
url: "https://piston-data.mojang.com/client.jar".to_string(),
|
|
source: "official".to_string(),
|
|
bytes_downloaded: 400,
|
|
bytes_total: Some(1_000),
|
|
attempt: 1,
|
|
max_attempts: 3,
|
|
status: DownloadItemStatus::Downloading,
|
|
last_reported_bytes: 400,
|
|
last_progress_at: Utc::now(),
|
|
speed_bytes_per_second: Some(200),
|
|
speed_sample_started_at: Utc::now(),
|
|
speed_sample_started_bytes: 400,
|
|
},
|
|
);
|
|
|
|
let items = job.download_items();
|
|
assert_eq!(items[0].bytes_downloaded, 400);
|
|
assert_eq!(items[0].status, DownloadItemStatus::Downloading);
|
|
let summary = job.download_summary();
|
|
assert_eq!(summary.speed_bytes_per_second, Some(200));
|
|
assert_eq!(summary.eta_seconds, Some(3));
|
|
|
|
job.active_downloads
|
|
.get_mut("client.jar")
|
|
.unwrap()
|
|
.last_progress_at = Utc::now() - TimeDelta::seconds(4);
|
|
let stalled = job.download_summary();
|
|
assert_eq!(stalled.speed_bytes_per_second, None);
|
|
assert_eq!(stalled.eta_seconds, None);
|
|
job.record_event(InstallJobEventKind::JobCanceled {
|
|
phase: InstallPhaseId::DownloadingMinecraft,
|
|
});
|
|
assert!(job.active_downloads.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn canceling_a_job_clears_active_download_requests() {
|
|
let mut job = job_state();
|
|
job.record_event(InstallJobEventKind::DownloadRequestStarted {
|
|
path: "mods/a.jar".to_string(),
|
|
name: "a.jar".to_string(),
|
|
url: "https://cdn.modrinth.com/data/a.jar".to_string(),
|
|
source: "official".to_string(),
|
|
bytes_total: Some(1024),
|
|
attempt: 1,
|
|
max_attempts: 2,
|
|
});
|
|
job.record_event(InstallJobEventKind::JobCanceled {
|
|
phase: InstallPhaseId::DownloadingContent,
|
|
});
|
|
|
|
let items = job.download_items();
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].status, DownloadItemStatus::Canceled);
|
|
}
|
|
|
|
#[test]
|
|
fn minecraft_download_progress_includes_byte_details() {
|
|
let mut job = job_state();
|
|
job.set_progress(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
Some(InstallProgress {
|
|
current: 125,
|
|
total: 500,
|
|
secondary: None,
|
|
}),
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
|
|
let summary = job.download_summary();
|
|
assert_eq!(summary.bytes_downloaded, 125);
|
|
assert_eq!(summary.bytes_total, Some(500));
|
|
}
|
|
|
|
#[test]
|
|
fn download_summary_tracks_metrics_and_java_progress() {
|
|
let mut job = job_state();
|
|
job.set_progress(
|
|
InstallPhaseId::PreparingJava,
|
|
Some(InstallProgress {
|
|
current: 1_024,
|
|
total: 2_048,
|
|
secondary: None,
|
|
}),
|
|
InstallPhaseDetails::Java {
|
|
major_version: 21,
|
|
step: InstallJavaStep::Downloading,
|
|
},
|
|
);
|
|
job.events.last_mut().unwrap().at = Utc::now() - TimeDelta::seconds(2);
|
|
job.record_event(InstallJobEventKind::DownloadMetrics {
|
|
source: "bmclapi".to_string(),
|
|
fallback_count: u64::MAX,
|
|
});
|
|
job.record_event(InstallJobEventKind::DownloadMetrics {
|
|
source: "official".to_string(),
|
|
fallback_count: 1,
|
|
});
|
|
|
|
let summary = job.download_summary();
|
|
assert_eq!(summary.bytes_downloaded, 1_024);
|
|
assert_eq!(summary.bytes_total, Some(2_048));
|
|
assert_eq!(summary.source.as_deref(), Some("official"));
|
|
assert_eq!(summary.fallback_count, u64::MAX);
|
|
assert_eq!(summary.speed_bytes_per_second, None);
|
|
assert_eq!(summary.eta_seconds, None);
|
|
}
|
|
|
|
#[test]
|
|
fn curseforge_instance_jobs_use_the_curseforge_provider() {
|
|
let job = InstallJobState::new(InstallRequest::CreateInstance {
|
|
name: "CurseForge pack".to_string(),
|
|
game_version: "1.20.1".to_string(),
|
|
loader: ModLoader::Forge,
|
|
loader_version: Some("latest".to_string()),
|
|
adjuncts: Vec::new(),
|
|
icon_path: None,
|
|
link: InstanceLink::CurseForgeModpack {
|
|
project_id: "123".to_string(),
|
|
version_id: "456".to_string(),
|
|
},
|
|
game_dir_override: None,
|
|
});
|
|
|
|
assert_eq!(job.provider(), InstallJobProvider::CurseForge);
|
|
}
|
|
|
|
#[test]
|
|
fn curseforge_managed_modpack_updates_are_tracked_as_pack_install_jobs() {
|
|
let job = InstallJobState::new(
|
|
InstallRequest::UpdateManagedCurseForgeModpack {
|
|
instance_id: "instance".to_string(),
|
|
file_id: 456,
|
|
},
|
|
);
|
|
|
|
assert_eq!(job.provider(), InstallJobProvider::CurseForge);
|
|
assert_eq!(
|
|
job.request.kind(),
|
|
InstallJobKind::InstallPackToExistingInstance
|
|
);
|
|
assert!(job.request.completes_instance_install_stage());
|
|
assert!(matches!(
|
|
job.request.target(),
|
|
InstallTarget::ExistingInstance { .. }
|
|
));
|
|
assert!(matches!(
|
|
job.request.cleanup(),
|
|
InstallCleanup::RestoreExistingInstance { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_instance_is_exposed_by_download_snapshot_state() {
|
|
let mut job = job_state();
|
|
assert!(!job.instance_deleted());
|
|
job.record_event(InstallJobEventKind::TargetInstanceDeleted {
|
|
instance_id: "deleted-instance".to_string(),
|
|
});
|
|
assert!(job.instance_deleted());
|
|
}
|
|
|
|
#[test]
|
|
fn canceling_and_waiting_statuses_round_trip() {
|
|
for status in [
|
|
InstallJobStatus::Canceling,
|
|
InstallJobStatus::WaitingForUser,
|
|
] {
|
|
assert_eq!(
|
|
InstallJobStatus::from_stored_str(status.as_str()),
|
|
status
|
|
);
|
|
assert!(!status.is_finished());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn waiting_job_state_round_trip_preserves_pause_and_file_items() {
|
|
let mut job = job_state();
|
|
for path in ["mods/complete.jar", "mods/missing.jar"] {
|
|
job.record_event(InstallJobEventKind::ContentFileQueued {
|
|
path: path.to_string(),
|
|
bytes_total: Some(100),
|
|
max_attempts: 4,
|
|
});
|
|
}
|
|
job.record_event(InstallJobEventKind::ContentFileCompleted {
|
|
path: "mods/complete.jar".to_string(),
|
|
bytes: 100,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileFailed {
|
|
path: "mods/missing.jar".to_string(),
|
|
reason: "network failure".to_string(),
|
|
project_id: None,
|
|
version_id: None,
|
|
});
|
|
let reason = InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec!["mods/missing.jar".to_string()],
|
|
};
|
|
job.pause_reason = Some(reason.clone());
|
|
job.continuation =
|
|
Some(InstallContinuationState::InstallingPackToExistingInstance {
|
|
disabled_project_ids: vec!["project-a".to_string()],
|
|
});
|
|
job.record_event(InstallJobEventKind::WaitingForUser {
|
|
reason: reason.clone(),
|
|
});
|
|
|
|
let restored: InstallJobState =
|
|
serde_json::from_str(&serde_json::to_string(&job).unwrap())
|
|
.unwrap();
|
|
let items = restored.download_items();
|
|
|
|
assert_eq!(restored.pause_reason, Some(reason));
|
|
assert_eq!(restored.continuation, job.continuation);
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0].status, DownloadItemStatus::Completed);
|
|
assert_eq!(items[1].status, DownloadItemStatus::Failed);
|
|
}
|
|
|
|
fn upgrade_execution() -> InstanceUpgradeExecution {
|
|
let environment = InstanceUpgradeEnvironment {
|
|
game_version: "1.21.1".to_string(),
|
|
mod_loader: ModLoader::Fabric,
|
|
mod_loader_version: Some("0.16.0".to_string()),
|
|
shader_runtime: crate::state::ShaderRuntime::Iris,
|
|
};
|
|
InstanceUpgradeExecution {
|
|
source_revision: 5,
|
|
source_files: Vec::new(),
|
|
source_environment: environment.clone(),
|
|
target_environment: environment,
|
|
items: Vec::new(),
|
|
solution: InstanceUpgradeSolution {
|
|
kind: crate::state::InstanceUpgradeSolutionKind::Custom,
|
|
selections: Vec::new(),
|
|
dependency_changes: Vec::new(),
|
|
warnings: Vec::new(),
|
|
},
|
|
warnings: Vec::new(),
|
|
source_watch: None,
|
|
}
|
|
}
|
|
|
|
fn upgrade_request(mode: SharedUpgradeMode) -> InstallRequest {
|
|
InstallRequest::UpgradeUnmanagedInstance {
|
|
instance_id: "source".to_string(),
|
|
plan_id: "plan".to_string(),
|
|
execution: upgrade_execution(),
|
|
create_full_backup: true,
|
|
shared_upgrade_mode: mode,
|
|
display_names: InstanceUpgradeDisplayNames::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_direct_targets_existing_instance() {
|
|
assert_eq!(
|
|
upgrade_request(SharedUpgradeMode::Direct).target(),
|
|
InstallTarget::ExistingInstance {
|
|
instance_id: "source".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_snapshot_source_identity_comes_from_request() {
|
|
let job = InstallJobState::new(upgrade_request(
|
|
SharedUpgradeMode::CopyAndUpgrade,
|
|
));
|
|
assert_eq!(job.source_instance_id().as_deref(), Some("source"));
|
|
let other = InstallJobState::new(InstallRequest::DownloadJava {
|
|
vendor: "test".to_string(),
|
|
version: 21,
|
|
});
|
|
assert_eq!(other.source_instance_id(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_direct_uses_technical_rollback() {
|
|
assert_eq!(
|
|
upgrade_request(SharedUpgradeMode::Direct).cleanup(),
|
|
InstallCleanup::RestoreExistingInstance {
|
|
instance_id: "source".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_copy_targets_new_physical_instance() {
|
|
assert_eq!(
|
|
upgrade_request(SharedUpgradeMode::CopyAndUpgrade).target(),
|
|
InstallTarget::NewInstance { instance_id: None }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_copy_failure_deletes_incomplete_copy() {
|
|
assert_eq!(
|
|
upgrade_request(SharedUpgradeMode::CopyAndUpgrade).cleanup(),
|
|
InstallCleanup::DeleteNewInstance { instance_id: None }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_is_persistent_install_job_kind() {
|
|
let request = upgrade_request(SharedUpgradeMode::Direct);
|
|
assert_eq!(request.kind(), InstallJobKind::UpgradeUnmanagedInstance);
|
|
assert_eq!(request.kind().as_str(), "upgrade_unmanaged_instance");
|
|
assert_eq!(
|
|
InstallJobKind::from_stored_str("upgrade_unmanaged_instance"),
|
|
InstallJobKind::UpgradeUnmanagedInstance
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_completes_install_stage() {
|
|
assert!(
|
|
upgrade_request(SharedUpgradeMode::Direct)
|
|
.completes_instance_install_stage()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_request_round_trip_freezes_solution() {
|
|
let mut request = upgrade_request(SharedUpgradeMode::Direct);
|
|
let InstallRequest::UpgradeUnmanagedInstance { display_names, .. } =
|
|
&mut request
|
|
else {
|
|
panic!("wrong request variant");
|
|
};
|
|
display_names.backup = Some("实例(升级前备份)".to_string());
|
|
display_names.should_auto_rename = true;
|
|
let value = serde_json::to_value(&request).unwrap();
|
|
let restored: InstallRequest = serde_json::from_value(value).unwrap();
|
|
let InstallRequest::UpgradeUnmanagedInstance {
|
|
plan_id,
|
|
execution,
|
|
display_names,
|
|
..
|
|
} = restored
|
|
else {
|
|
panic!("wrong request variant");
|
|
};
|
|
assert_eq!(plan_id, "plan");
|
|
assert_eq!(execution.source_revision, 5);
|
|
assert_eq!(display_names.backup.as_deref(), Some("实例(升级前备份)"));
|
|
assert!(display_names.should_auto_rename);
|
|
assert_eq!(
|
|
execution.solution.kind,
|
|
crate::state::InstanceUpgradeSolutionKind::Custom
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn old_instance_upgrade_request_defaults_display_names() {
|
|
let mut value =
|
|
serde_json::to_value(upgrade_request(SharedUpgradeMode::Direct))
|
|
.unwrap();
|
|
value.as_object_mut().unwrap().remove("display_names");
|
|
let restored: InstallRequest = serde_json::from_value(value).unwrap();
|
|
let InstallRequest::UpgradeUnmanagedInstance { display_names, .. } =
|
|
restored
|
|
else {
|
|
panic!("wrong request variant");
|
|
};
|
|
assert_eq!(display_names, InstanceUpgradeDisplayNames::default());
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_progress_phases_round_trip() {
|
|
for phase in [
|
|
InstallPhaseId::CreatingBackup,
|
|
InstallPhaseId::StagingContent,
|
|
InstallPhaseId::DownloadingContent,
|
|
InstallPhaseId::ApplyingContent,
|
|
InstallPhaseId::UpdatingLoader,
|
|
InstallPhaseId::Verifying,
|
|
InstallPhaseId::Completed,
|
|
] {
|
|
assert_eq!(
|
|
serde_json::from_value::<InstallPhaseId>(
|
|
serde_json::to_value(phase).unwrap()
|
|
)
|
|
.unwrap(),
|
|
phase
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_external_change_event_round_trip() {
|
|
let event = InstallJobEventKind::UpgradeExternalChange {
|
|
relative_path: "mods/user.jar".to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Modified,
|
|
};
|
|
let restored: InstallJobEventKind =
|
|
serde_json::from_value(serde_json::to_value(&event).unwrap())
|
|
.unwrap();
|
|
assert!(matches!(
|
|
restored,
|
|
InstallJobEventKind::UpgradeExternalChange {
|
|
kind: InstanceUpgradeExternalChangeKind::Modified,
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn instance_upgrade_result_round_trip_preserves_backup_and_skips() {
|
|
let execution = upgrade_execution();
|
|
let result = InstanceUpgradeResult {
|
|
plan_id: "plan".to_string(),
|
|
source_instance_id: "source".to_string(),
|
|
target_instance_id: "target".to_string(),
|
|
backup_instance_id: Some("backup".to_string()),
|
|
source_environment: Some(execution.source_environment.clone()),
|
|
target_environment: Some(execution.target_environment.clone()),
|
|
solution: execution.solution,
|
|
compatibility_warnings: Vec::new(),
|
|
compatibility_warning_details: Vec::new(),
|
|
external_changes: vec![InstanceUpgradeExternalChange {
|
|
relative_path: "mods/user.jar".to_string(),
|
|
kind: InstanceUpgradeExternalChangeKind::Added,
|
|
}],
|
|
skipped_due_to_external_conflict: vec!["mods/user.jar".to_string()],
|
|
};
|
|
let restored: InstanceUpgradeResult =
|
|
serde_json::from_value(serde_json::to_value(&result).unwrap())
|
|
.unwrap();
|
|
assert_eq!(restored.backup_instance_id.as_deref(), Some("backup"));
|
|
assert_eq!(
|
|
restored.source_environment,
|
|
Some(execution.source_environment)
|
|
);
|
|
assert_eq!(
|
|
restored.target_environment,
|
|
Some(execution.target_environment)
|
|
);
|
|
assert_eq!(restored.external_changes.len(), 1);
|
|
assert_eq!(restored.skipped_due_to_external_conflict.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn old_instance_upgrade_result_defaults_additive_fields() {
|
|
let execution = upgrade_execution();
|
|
let result = InstanceUpgradeResult {
|
|
plan_id: "plan".to_string(),
|
|
source_instance_id: "source".to_string(),
|
|
target_instance_id: "target".to_string(),
|
|
backup_instance_id: None,
|
|
source_environment: Some(execution.source_environment),
|
|
target_environment: Some(execution.target_environment),
|
|
solution: execution.solution,
|
|
compatibility_warnings: Vec::new(),
|
|
compatibility_warning_details: Vec::new(),
|
|
external_changes: Vec::new(),
|
|
skipped_due_to_external_conflict: Vec::new(),
|
|
};
|
|
let mut value = serde_json::to_value(result).unwrap();
|
|
let object = value.as_object_mut().unwrap();
|
|
object.remove("sourceEnvironment");
|
|
object.remove("targetEnvironment");
|
|
object.remove("compatibilityWarningDetails");
|
|
|
|
let restored: InstanceUpgradeResult =
|
|
serde_json::from_value(value).unwrap();
|
|
assert_eq!(restored.source_environment, None);
|
|
assert_eq!(restored.target_environment, None);
|
|
assert!(restored.compatibility_warning_details.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn old_install_job_state_defaults_upgrade_result_to_none() {
|
|
let job = job_state();
|
|
let mut value = serde_json::to_value(job).unwrap();
|
|
value.as_object_mut().unwrap().remove("upgrade_result");
|
|
let restored: InstallJobState = serde_json::from_value(value).unwrap();
|
|
assert!(restored.upgrade_result.is_none());
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
},
|
|
WaitingForUser {
|
|
reason: InstallPauseReason,
|
|
},
|
|
PhaseStarted {
|
|
phase: InstallPhaseId,
|
|
details: InstallPhaseDetails,
|
|
},
|
|
ContentDownloadStarted {
|
|
files: u64,
|
|
bytes: Option<u64>,
|
|
},
|
|
ContentFileQueued {
|
|
path: String,
|
|
bytes_total: Option<u64>,
|
|
max_attempts: u32,
|
|
},
|
|
ContentFileBrowserOptions {
|
|
path: String,
|
|
urls: Vec<String>,
|
|
},
|
|
ContentFileVerificationStarted {
|
|
path: String,
|
|
},
|
|
ContentFileWritingStarted {
|
|
path: String,
|
|
},
|
|
ContentFileRecovered {
|
|
path: String,
|
|
bytes: u64,
|
|
},
|
|
ContentFileDownloadAttempt {
|
|
path: String,
|
|
bytes_total: Option<u64>,
|
|
attempt: u32,
|
|
max_attempts: u32,
|
|
},
|
|
DownloadRequestStarted {
|
|
path: String,
|
|
name: String,
|
|
url: String,
|
|
source: String,
|
|
bytes_total: Option<u64>,
|
|
attempt: u32,
|
|
max_attempts: u32,
|
|
},
|
|
DownloadRequestFinished {
|
|
path: String,
|
|
bytes: u64,
|
|
},
|
|
DownloadRequestFailed {
|
|
path: String,
|
|
},
|
|
ContentFileSkipped {
|
|
path: String,
|
|
reason: String,
|
|
#[serde(default)]
|
|
project_id: Option<String>,
|
|
#[serde(default)]
|
|
version_id: Option<String>,
|
|
#[serde(default)]
|
|
manual_url: Option<String>,
|
|
},
|
|
ContentFileFailed {
|
|
path: String,
|
|
reason: String,
|
|
#[serde(default)]
|
|
project_id: Option<String>,
|
|
#[serde(default)]
|
|
version_id: Option<String>,
|
|
},
|
|
ContentFileCompleted {
|
|
path: String,
|
|
bytes: u64,
|
|
},
|
|
DownloadMetrics {
|
|
source: String,
|
|
fallback_count: u64,
|
|
},
|
|
UpgradeExternalChange {
|
|
relative_path: String,
|
|
kind: InstanceUpgradeExternalChangeKind,
|
|
},
|
|
UpgradeItemSkipped {
|
|
relative_path: String,
|
|
reason: String,
|
|
},
|
|
TargetInstanceDeleted {
|
|
instance_id: String,
|
|
},
|
|
Interrupted {
|
|
reason: InstallInterruptReason,
|
|
phase: InstallPhaseId,
|
|
},
|
|
Failed {
|
|
phase: InstallPhaseId,
|
|
code: String,
|
|
message: String,
|
|
},
|
|
RollbackStarted {
|
|
cleanup: InstallCleanup,
|
|
},
|
|
RollbackCompleted,
|
|
RollbackFailed {
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum InstallRequest {
|
|
CreateInstance {
|
|
name: String,
|
|
game_version: String,
|
|
loader: ModLoader,
|
|
loader_version: Option<String>,
|
|
#[serde(default)]
|
|
adjuncts: Vec<crate::state::LoaderComponent>,
|
|
icon_path: Option<String>,
|
|
link: InstanceLink,
|
|
#[serde(default)]
|
|
game_dir_override: Option<String>,
|
|
},
|
|
CreateModpackInstance {
|
|
location: CreatePackLocation,
|
|
#[serde(default)]
|
|
post_install_edit: Option<InstallPostInstallEdit>,
|
|
},
|
|
ImportInstance {
|
|
launcher_type: ImportLauncherType,
|
|
base_path: PathBuf,
|
|
instance_folder: String,
|
|
#[serde(default)]
|
|
instance_path: Option<String>,
|
|
#[serde(default)]
|
|
symlink: bool,
|
|
#[serde(default)]
|
|
game_version: Option<String>,
|
|
#[serde(default)]
|
|
loader: Option<ModLoader>,
|
|
#[serde(default)]
|
|
loader_version: Option<String>,
|
|
#[serde(default)]
|
|
game_dir_override: Option<String>,
|
|
},
|
|
DuplicateInstance {
|
|
source_instance_id: String,
|
|
},
|
|
InstallExistingInstance {
|
|
instance_id: String,
|
|
force: bool,
|
|
},
|
|
UpgradeUnmanagedInstance {
|
|
instance_id: String,
|
|
plan_id: String,
|
|
execution: InstanceUpgradeExecution,
|
|
create_full_backup: bool,
|
|
shared_upgrade_mode: SharedUpgradeMode,
|
|
#[serde(default)]
|
|
display_names: InstanceUpgradeDisplayNames,
|
|
},
|
|
InstallPackToExistingInstance {
|
|
instance_id: String,
|
|
location: CreatePackLocation,
|
|
#[serde(default)]
|
|
post_install_edit: Option<InstallPostInstallEdit>,
|
|
},
|
|
UpdateManagedCurseForgeModpack {
|
|
instance_id: String,
|
|
file_id: u32,
|
|
},
|
|
InstallContent {
|
|
instance_id: String,
|
|
project_id: String,
|
|
version_id: Option<String>,
|
|
content_type: ContentType,
|
|
#[serde(default)]
|
|
selected: ResolutionPreferences,
|
|
#[serde(default)]
|
|
excluded_project_ids: Vec<String>,
|
|
display_title: String,
|
|
#[serde(default)]
|
|
display_icon: Option<String>,
|
|
},
|
|
InstallCurseForgeContent {
|
|
request: CurseForgeInstallRequest,
|
|
display_title: String,
|
|
#[serde(default)]
|
|
display_icon: Option<String>,
|
|
},
|
|
InstallCurseForgeWorld {
|
|
request: CurseForgeWorldInstallRequest,
|
|
display_title: String,
|
|
#[serde(default)]
|
|
display_icon: Option<String>,
|
|
},
|
|
DownloadJava {
|
|
vendor: String,
|
|
version: u32,
|
|
},
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
pub struct InstallPostInstallEdit {
|
|
pub name: Option<String>,
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Option::is_none",
|
|
with = "serde_with::rust::double_option"
|
|
)]
|
|
pub icon_path: Option<Option<String>>,
|
|
pub link: Option<InstanceLink>,
|
|
}
|
|
|
|
impl InstallRequest {
|
|
pub(crate) fn completes_instance_install_stage(&self) -> bool {
|
|
match self {
|
|
Self::CreateInstance { .. }
|
|
| Self::CreateModpackInstance { .. }
|
|
| Self::ImportInstance { .. }
|
|
| Self::DuplicateInstance { .. }
|
|
| Self::InstallExistingInstance { .. }
|
|
| Self::UpgradeUnmanagedInstance { .. }
|
|
| Self::InstallPackToExistingInstance { .. }
|
|
| Self::UpdateManagedCurseForgeModpack { .. } => true,
|
|
Self::InstallContent { .. }
|
|
| Self::InstallCurseForgeContent { .. }
|
|
| Self::InstallCurseForgeWorld { .. }
|
|
| Self::DownloadJava { .. } => false,
|
|
}
|
|
}
|
|
|
|
pub fn kind(&self) -> InstallJobKind {
|
|
match self {
|
|
Self::CreateInstance { .. } => InstallJobKind::CreateInstance,
|
|
Self::CreateModpackInstance { .. } => {
|
|
InstallJobKind::CreateModpackInstance
|
|
}
|
|
Self::ImportInstance { .. } => InstallJobKind::ImportInstance,
|
|
Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance,
|
|
Self::InstallExistingInstance { .. } => {
|
|
InstallJobKind::InstallExistingInstance
|
|
}
|
|
Self::UpgradeUnmanagedInstance { .. } => {
|
|
InstallJobKind::UpgradeUnmanagedInstance
|
|
}
|
|
Self::InstallPackToExistingInstance { .. } => {
|
|
InstallJobKind::InstallPackToExistingInstance
|
|
}
|
|
Self::UpdateManagedCurseForgeModpack { .. } => {
|
|
InstallJobKind::InstallPackToExistingInstance
|
|
}
|
|
Self::InstallContent { .. } => InstallJobKind::InstallContent,
|
|
Self::InstallCurseForgeContent { .. } => {
|
|
InstallJobKind::InstallContent
|
|
}
|
|
Self::InstallCurseForgeWorld { .. } => {
|
|
InstallJobKind::InstallContent
|
|
}
|
|
Self::DownloadJava { .. } => InstallJobKind::DownloadJava,
|
|
}
|
|
}
|
|
|
|
pub fn target(&self) -> InstallTarget {
|
|
match self {
|
|
Self::UpgradeUnmanagedInstance {
|
|
instance_id,
|
|
shared_upgrade_mode,
|
|
..
|
|
} => match shared_upgrade_mode {
|
|
SharedUpgradeMode::Direct => InstallTarget::ExistingInstance {
|
|
instance_id: instance_id.clone(),
|
|
},
|
|
SharedUpgradeMode::CopyAndUpgrade => {
|
|
InstallTarget::NewInstance { instance_id: None }
|
|
}
|
|
},
|
|
Self::InstallExistingInstance { instance_id, .. }
|
|
| Self::InstallPackToExistingInstance { instance_id, .. }
|
|
| Self::UpdateManagedCurseForgeModpack { instance_id, .. }
|
|
| Self::InstallContent { instance_id, .. } => {
|
|
InstallTarget::ExistingInstance {
|
|
instance_id: instance_id.clone(),
|
|
}
|
|
}
|
|
Self::InstallCurseForgeContent { request, .. } => {
|
|
InstallTarget::ExistingInstance {
|
|
instance_id: request.instance_id.clone(),
|
|
}
|
|
}
|
|
Self::InstallCurseForgeWorld { request, .. } => {
|
|
InstallTarget::ExistingInstance {
|
|
instance_id: request.instance_id.clone(),
|
|
}
|
|
}
|
|
_ => InstallTarget::NewInstance { instance_id: None },
|
|
}
|
|
}
|
|
|
|
pub fn cleanup(&self) -> InstallCleanup {
|
|
match self {
|
|
Self::UpgradeUnmanagedInstance {
|
|
instance_id,
|
|
shared_upgrade_mode,
|
|
..
|
|
} => match shared_upgrade_mode {
|
|
SharedUpgradeMode::Direct => {
|
|
InstallCleanup::RestoreExistingInstance {
|
|
instance_id: instance_id.clone(),
|
|
}
|
|
}
|
|
SharedUpgradeMode::CopyAndUpgrade => {
|
|
InstallCleanup::DeleteNewInstance { instance_id: None }
|
|
}
|
|
},
|
|
Self::InstallExistingInstance { instance_id, .. }
|
|
| Self::InstallPackToExistingInstance { instance_id, .. }
|
|
| Self::UpdateManagedCurseForgeModpack { instance_id, .. } => {
|
|
InstallCleanup::RestoreExistingInstance {
|
|
instance_id: instance_id.clone(),
|
|
}
|
|
}
|
|
Self::InstallContent { .. } => InstallCleanup::None,
|
|
Self::InstallCurseForgeContent { .. } => InstallCleanup::None,
|
|
Self::InstallCurseForgeWorld { .. } => InstallCleanup::None,
|
|
_ => InstallCleanup::DeleteNewInstance { instance_id: None },
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallJobKind {
|
|
CreateInstance,
|
|
CreateModpackInstance,
|
|
ImportInstance,
|
|
DuplicateInstance,
|
|
InstallExistingInstance,
|
|
UpgradeUnmanagedInstance,
|
|
InstallPackToExistingInstance,
|
|
InstallContent,
|
|
DownloadJava,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallJobProvider {
|
|
Modrinth,
|
|
CurseForge,
|
|
Minecraft,
|
|
Java,
|
|
Application,
|
|
Local,
|
|
}
|
|
|
|
impl InstallJobProvider {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Modrinth => "modrinth",
|
|
Self::CurseForge => "curse_forge",
|
|
Self::Minecraft => "minecraft",
|
|
Self::Java => "java",
|
|
Self::Application => "application",
|
|
Self::Local => "local",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DownloadItemStatus {
|
|
Queued,
|
|
WorkerStarted,
|
|
WaitingForResource,
|
|
Connecting,
|
|
Downloading,
|
|
Verifying,
|
|
Writing,
|
|
Metadata,
|
|
WaitingForDatabase,
|
|
Finalizing,
|
|
WaitingForUser,
|
|
Completed,
|
|
Skipped,
|
|
Failed,
|
|
Canceled,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct ActiveDownloadState {
|
|
pub name: String,
|
|
pub url: String,
|
|
pub source: String,
|
|
pub bytes_downloaded: u64,
|
|
pub bytes_total: Option<u64>,
|
|
pub attempt: u32,
|
|
pub max_attempts: u32,
|
|
pub status: DownloadItemStatus,
|
|
#[serde(default)]
|
|
pub last_reported_bytes: u64,
|
|
pub last_progress_at: DateTime<Utc>,
|
|
#[serde(default)]
|
|
pub speed_bytes_per_second: Option<u64>,
|
|
#[serde(default = "Utc::now")]
|
|
pub speed_sample_started_at: DateTime<Utc>,
|
|
#[serde(default)]
|
|
pub speed_sample_started_bytes: u64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct DownloadItemSnapshot {
|
|
pub id: String,
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub project_id: Option<String>,
|
|
#[serde(default)]
|
|
pub version_id: Option<String>,
|
|
pub status: DownloadItemStatus,
|
|
pub bytes_downloaded: u64,
|
|
pub bytes_total: Option<u64>,
|
|
#[serde(default)]
|
|
pub attempt: Option<u32>,
|
|
#[serde(default)]
|
|
pub max_attempts: Option<u32>,
|
|
pub error: Option<String>,
|
|
pub manual_url: Option<String>,
|
|
pub request_url: Option<String>,
|
|
pub source: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
pub struct DownloadJobSummary {
|
|
pub files_completed: u64,
|
|
pub files_total: Option<u64>,
|
|
pub bytes_downloaded: u64,
|
|
pub bytes_total: Option<u64>,
|
|
pub speed_bytes_per_second: Option<u64>,
|
|
pub eta_seconds: Option<u64>,
|
|
pub source: Option<String>,
|
|
pub fallback_count: u64,
|
|
}
|
|
|
|
impl InstallJobKind {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::CreateInstance => "create_instance",
|
|
Self::CreateModpackInstance => "create_modpack_instance",
|
|
Self::ImportInstance => "import_instance",
|
|
Self::DuplicateInstance => "duplicate_instance",
|
|
Self::InstallExistingInstance => "install_existing_instance",
|
|
Self::UpgradeUnmanagedInstance => "upgrade_unmanaged_instance",
|
|
Self::InstallPackToExistingInstance => {
|
|
"install_pack_to_existing_instance"
|
|
}
|
|
Self::InstallContent => "install_content",
|
|
Self::DownloadJava => "download_java",
|
|
}
|
|
}
|
|
|
|
pub fn from_stored_str(value: &str) -> Self {
|
|
match value {
|
|
"create_modpack_instance" => Self::CreateModpackInstance,
|
|
"import_instance" => Self::ImportInstance,
|
|
"duplicate_instance" => Self::DuplicateInstance,
|
|
"install_existing_instance" => Self::InstallExistingInstance,
|
|
"upgrade_unmanaged_instance" => Self::UpgradeUnmanagedInstance,
|
|
"install_pack_to_existing_instance" => {
|
|
Self::InstallPackToExistingInstance
|
|
}
|
|
"install_content" => Self::InstallContent,
|
|
"download_java" => Self::DownloadJava,
|
|
_ => Self::CreateInstance,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallJobStatus {
|
|
Queued,
|
|
Running,
|
|
Canceling,
|
|
WaitingForUser,
|
|
Succeeded,
|
|
Failed,
|
|
Interrupted,
|
|
Canceled,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallJobExecutionMode {
|
|
Normal,
|
|
RecoveryValidation,
|
|
}
|
|
|
|
impl InstallJobStatus {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Queued => "queued",
|
|
Self::Running => "running",
|
|
Self::Canceling => "canceling",
|
|
Self::WaitingForUser => "waiting_for_user",
|
|
Self::Succeeded => "succeeded",
|
|
Self::Failed => "failed",
|
|
Self::Interrupted => "interrupted",
|
|
Self::Canceled => "canceled",
|
|
}
|
|
}
|
|
|
|
pub fn from_stored_str(value: &str) -> Self {
|
|
match value {
|
|
"running" => Self::Running,
|
|
"canceling" => Self::Canceling,
|
|
"waiting_for_user" => Self::WaitingForUser,
|
|
"succeeded" => Self::Succeeded,
|
|
"failed" => Self::Failed,
|
|
"interrupted" => Self::Interrupted,
|
|
"canceled" => Self::Canceled,
|
|
_ => Self::Queued,
|
|
}
|
|
}
|
|
|
|
pub fn is_finished(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Succeeded | Self::Failed | Self::Interrupted | Self::Canceled
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum InstallTarget {
|
|
NewInstance { instance_id: Option<String> },
|
|
ExistingInstance { instance_id: String },
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum InstallCleanup {
|
|
DeleteNewInstance { instance_id: Option<String> },
|
|
RestoreExistingInstance { instance_id: String },
|
|
None,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallProgressState {
|
|
pub phase: InstallPhaseId,
|
|
pub progress: Option<InstallProgress>,
|
|
pub details: InstallPhaseDetails,
|
|
/// A concurrently running secondary track (e.g. the Minecraft core
|
|
/// download while modpack content is being installed). The frontend
|
|
/// renders it as a separate progress bar alongside the main phase.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub parallel: Option<InstallParallelProgress>,
|
|
}
|
|
|
|
/// Progress of a parallel install track that runs while the main phase
|
|
/// advances. Self-contained so the UI can render an independent bar.
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallParallelProgress {
|
|
pub phase: InstallPhaseId,
|
|
pub current: u64,
|
|
pub total: u64,
|
|
pub details: InstallPhaseDetails,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallPhaseId {
|
|
PreparingInstance,
|
|
CreatingBackup,
|
|
StagingContent,
|
|
ResolvingPack,
|
|
DownloadingPackFile,
|
|
ReadingPackManifest,
|
|
DownloadingContent,
|
|
ExtractingOverrides,
|
|
ResolvingMinecraft,
|
|
ResolvingLoader,
|
|
PreparingJava,
|
|
DownloadingMinecraft,
|
|
RunningLoaderProcessors,
|
|
ApplyingContent,
|
|
UpdatingLoader,
|
|
Verifying,
|
|
Finalizing,
|
|
Completed,
|
|
RollingBack,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallProgress {
|
|
pub current: u64,
|
|
pub total: u64,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub secondary: Option<InstallProgressSecondary>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallProgressSecondary {
|
|
pub current: u64,
|
|
pub total: u64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallJavaStep {
|
|
Resolving,
|
|
FetchingMetadata,
|
|
Downloading,
|
|
Extracting,
|
|
Validating,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum InstallPhaseDetails {
|
|
Empty,
|
|
Instance {
|
|
name: String,
|
|
},
|
|
Minecraft {
|
|
game_version: String,
|
|
loader: ModLoader,
|
|
},
|
|
Java {
|
|
major_version: u32,
|
|
step: InstallJavaStep,
|
|
},
|
|
Modpack {
|
|
project_id: Option<String>,
|
|
version_id: Option<String>,
|
|
title: Option<String>,
|
|
},
|
|
Import {
|
|
launcher_type: ImportLauncherType,
|
|
instance_folder: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
pub struct InstallJobPaths {
|
|
pub staging_dir: Option<PathBuf>,
|
|
pub final_instance_path: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, 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 = "Vec::is_empty")]
|
|
#[builder(default)]
|
|
pub cache_types: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[builder(into)]
|
|
pub sqlite_code: Option<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,
|
|
pub icon: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallRollbackState {
|
|
pub instance: InstanceMetadata,
|
|
pub install_stage: InstanceInstallStage,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub content: Option<InstallContentRollbackSnapshot>,
|
|
}
|
|
|
|
#[derive(
|
|
Serialize, Deserialize, Clone, Copy, Debug, Default, Eq, PartialEq,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallContentRollbackScope {
|
|
#[default]
|
|
PackManaged,
|
|
AllContent,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallContentRollbackStage {
|
|
Planned,
|
|
Ready,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallContentRollbackSnapshot {
|
|
pub staging_id: String,
|
|
pub stage: InstallContentRollbackStage,
|
|
#[serde(default)]
|
|
pub scope: InstallContentRollbackScope,
|
|
pub files: Vec<InstallRollbackFile>,
|
|
pub entries: Vec<InstallRollbackContentEntry>,
|
|
#[serde(default)]
|
|
pub dependency_edges: Vec<ContentDependencyEdge>,
|
|
pub pack_members: Vec<PackMember>,
|
|
#[serde(default)]
|
|
pub replacement_paths: Vec<String>,
|
|
#[serde(default)]
|
|
pub protected_paths: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallRollbackFile {
|
|
pub relative_path: String,
|
|
pub staged_name: String,
|
|
pub existed: bool,
|
|
pub physical_sha1: Option<String>,
|
|
pub physical_size: Option<u64>,
|
|
pub instance_file: Option<InstanceFile>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallRollbackContentEntry {
|
|
pub entry: ContentEntry,
|
|
pub provider_refs: Vec<InstallRollbackProviderRef>,
|
|
pub update_check: Option<ContentUpdateCheck>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallRollbackProviderRef {
|
|
pub provider_ref: ContentProviderRef,
|
|
pub origin: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SharedUpgradeMode {
|
|
Direct,
|
|
CopyAndUpgrade,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, Eq, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstanceUpgradeDisplayNames {
|
|
#[serde(default)]
|
|
pub backup: Option<String>,
|
|
#[serde(default)]
|
|
pub copy: Option<String>,
|
|
#[serde(default)]
|
|
pub upgraded_target: Option<String>,
|
|
#[serde(default)]
|
|
pub should_auto_rename: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstanceUpgradeExecution {
|
|
pub source_revision: u64,
|
|
pub source_files: Vec<InstanceUpgradeSourceFile>,
|
|
pub source_environment: InstanceUpgradeEnvironment,
|
|
pub target_environment: InstanceUpgradeEnvironment,
|
|
pub items: Vec<InstanceUpgradeItem>,
|
|
pub solution: InstanceUpgradeSolution,
|
|
pub warnings: Vec<InstanceUpgradeIssue>,
|
|
#[serde(default)]
|
|
pub source_watch: Option<InstanceUpgradeWatchBaseline>,
|
|
}
|
|
|
|
impl InstanceUpgradeExecution {
|
|
pub(crate) fn final_physical_decision(
|
|
&self,
|
|
item: &InstanceUpgradeItem,
|
|
) -> (InstanceUpgradeAction, bool) {
|
|
self.solution
|
|
.selections
|
|
.iter()
|
|
.find(|selection| selection.content_id == item.content_id)
|
|
.map(|selection| {
|
|
(
|
|
selection.action,
|
|
selection.action != InstanceUpgradeAction::Disable
|
|
&& selection.enabled,
|
|
)
|
|
})
|
|
.unwrap_or_else(|| {
|
|
(
|
|
item.resolution.action,
|
|
item.resolution.action != InstanceUpgradeAction::Disable
|
|
&& item.current_enabled,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstanceUpgradeWatchBaseline {
|
|
pub epoch: u64,
|
|
pub generation: u64,
|
|
pub dirty_paths: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstanceUpgradeExternalChangeKind {
|
|
Added,
|
|
Removed,
|
|
Modified,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstanceUpgradeExternalChange {
|
|
pub relative_path: String,
|
|
pub kind: InstanceUpgradeExternalChangeKind,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstanceUpgradeCompatibilityWarning {
|
|
pub code: InstanceUpgradeIssueCode,
|
|
pub relative_path: Option<String>,
|
|
pub content_id: Option<String>,
|
|
pub provider: Option<ContentProvider>,
|
|
pub project_id: Option<String>,
|
|
pub conflicting_project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstanceUpgradeResult {
|
|
pub plan_id: String,
|
|
pub source_instance_id: String,
|
|
pub target_instance_id: String,
|
|
pub backup_instance_id: Option<String>,
|
|
#[serde(default)]
|
|
pub source_environment: Option<InstanceUpgradeEnvironment>,
|
|
#[serde(default)]
|
|
pub target_environment: Option<InstanceUpgradeEnvironment>,
|
|
pub solution: InstanceUpgradeSolution,
|
|
pub compatibility_warnings: Vec<InstanceUpgradeIssue>,
|
|
#[serde(default)]
|
|
pub compatibility_warning_details: Vec<InstanceUpgradeCompatibilityWarning>,
|
|
#[serde(default)]
|
|
pub external_changes: Vec<InstanceUpgradeExternalChange>,
|
|
#[serde(default)]
|
|
pub skipped_due_to_external_conflict: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
pub struct MissingModpackContentState {
|
|
pub files: Vec<MissingModpackFileState>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct MissingModpackFileState {
|
|
pub item_id: String,
|
|
pub manifest_path: String,
|
|
pub target_path: String,
|
|
pub expected_size: u64,
|
|
pub sha1: Option<String>,
|
|
pub sha512: Option<String>,
|
|
pub download_urls: Vec<String>,
|
|
pub browser_urls: Vec<String>,
|
|
#[serde(default)]
|
|
pub validate_as_jar: bool,
|
|
}
|
|
|
|
#[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,
|
|
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(),
|
|
})
|
|
}
|
|
crate::ErrorKind::HttpError {
|
|
status,
|
|
method,
|
|
url,
|
|
} => Some(InstallApiErrorDetails {
|
|
error: "http_error".to_string(),
|
|
status: Some(*status),
|
|
method: Some(method.clone()),
|
|
url: Some(url.clone()),
|
|
route: None,
|
|
}),
|
|
_ => 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct InstallJobSnapshot {
|
|
pub job_id: Uuid,
|
|
pub instance_id: Option<String>,
|
|
pub source_instance_id: Option<String>,
|
|
pub instance_deleted: bool,
|
|
pub kind: InstallJobKind,
|
|
pub status: InstallJobStatus,
|
|
pub execution_mode: InstallJobExecutionMode,
|
|
pub provider: InstallJobProvider,
|
|
pub target: InstallTarget,
|
|
pub phase: InstallPhaseId,
|
|
pub progress: Option<InstallProgress>,
|
|
pub details: InstallPhaseDetails,
|
|
pub parallel: Option<InstallParallelProgress>,
|
|
pub display: Option<InstallJobDisplay>,
|
|
pub error: Option<InstallErrorView>,
|
|
pub rollback_error: Option<InstallErrorView>,
|
|
pub pause_reason: Option<InstallPauseReason>,
|
|
pub upgrade_result: Option<InstanceUpgradeResult>,
|
|
pub created: DateTime<Utc>,
|
|
pub modified: DateTime<Utc>,
|
|
pub finished: Option<DateTime<Utc>>,
|
|
pub summary: DownloadJobSummary,
|
|
pub items: Vec<DownloadItemSnapshot>,
|
|
}
|
|
|
|
impl InstallJobState {
|
|
pub fn source_instance_id(&self) -> Option<String> {
|
|
match &self.request {
|
|
InstallRequest::UpgradeUnmanagedInstance {
|
|
instance_id, ..
|
|
} => Some(instance_id.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn execution_mode(
|
|
&self,
|
|
status: InstallJobStatus,
|
|
) -> InstallJobExecutionMode {
|
|
if !matches!(
|
|
status,
|
|
InstallJobStatus::Queued | InstallJobStatus::Running
|
|
) || self.progress.phase != InstallPhaseId::DownloadingContent
|
|
|| self.missing_content.is_none()
|
|
|| !matches!(
|
|
&self.request,
|
|
InstallRequest::CreateModpackInstance { .. }
|
|
| InstallRequest::InstallPackToExistingInstance { .. }
|
|
)
|
|
{
|
|
return InstallJobExecutionMode::Normal;
|
|
}
|
|
|
|
let last_missing_pause = self.events.iter().rposition(|event| {
|
|
matches!(
|
|
&event.kind,
|
|
InstallJobEventKind::WaitingForUser {
|
|
reason: InstallPauseReason::MissingRequiredContent { .. }
|
|
}
|
|
)
|
|
});
|
|
let last_queued = self.events.iter().rposition(|event| {
|
|
matches!(&event.kind, InstallJobEventKind::JobQueued { .. })
|
|
});
|
|
if last_missing_pause
|
|
.zip(last_queued)
|
|
.is_some_and(|(paused, queued)| queued > paused)
|
|
{
|
|
InstallJobExecutionMode::RecoveryValidation
|
|
} else {
|
|
InstallJobExecutionMode::Normal
|
|
}
|
|
}
|
|
|
|
pub fn instance_deleted(&self) -> bool {
|
|
self.events.iter().any(|event| {
|
|
matches!(
|
|
event.kind,
|
|
InstallJobEventKind::TargetInstanceDeleted { .. }
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn provider(&self) -> InstallJobProvider {
|
|
match &self.request {
|
|
InstallRequest::CreateModpackInstance { location, .. }
|
|
| InstallRequest::InstallPackToExistingInstance {
|
|
location, ..
|
|
} => match location {
|
|
CreatePackLocation::FromVersionId { .. } => {
|
|
InstallJobProvider::Modrinth
|
|
}
|
|
CreatePackLocation::FromFile { .. } => {
|
|
InstallJobProvider::Local
|
|
}
|
|
},
|
|
InstallRequest::CreateInstance { link, .. } => match link {
|
|
InstanceLink::CurseForgeModpack { .. } => {
|
|
InstallJobProvider::CurseForge
|
|
}
|
|
_ => InstallJobProvider::Minecraft,
|
|
},
|
|
InstallRequest::InstallExistingInstance { .. } => {
|
|
InstallJobProvider::Minecraft
|
|
}
|
|
InstallRequest::UpgradeUnmanagedInstance { .. } => {
|
|
InstallJobProvider::Application
|
|
}
|
|
InstallRequest::InstallContent { .. } => {
|
|
InstallJobProvider::Modrinth
|
|
}
|
|
InstallRequest::InstallCurseForgeContent { .. }
|
|
| InstallRequest::InstallCurseForgeWorld { .. }
|
|
| InstallRequest::UpdateManagedCurseForgeModpack { .. } => {
|
|
InstallJobProvider::CurseForge
|
|
}
|
|
InstallRequest::DownloadJava { .. } => InstallJobProvider::Java,
|
|
InstallRequest::ImportInstance { .. }
|
|
| InstallRequest::DuplicateInstance { .. } => {
|
|
InstallJobProvider::Local
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Replays `events` into download item snapshots. This is the expensive
|
|
/// part of `download_items` and its result is cached; the cheap
|
|
/// `active_downloads` overlay is applied live by the caller.
|
|
fn download_items_from_events(&self) -> Vec<DownloadItemSnapshot> {
|
|
let mut items = Vec::<DownloadItemSnapshot>::new();
|
|
let mut indices = HashMap::<String, usize>::new();
|
|
let mut content_items = HashSet::<String>::new();
|
|
for event in &self.events {
|
|
match &event.kind {
|
|
InstallJobEventKind::ContentFileQueued {
|
|
path,
|
|
bytes_total,
|
|
max_attempts,
|
|
} => {
|
|
content_items.insert(path.clone());
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Queued;
|
|
item.bytes_downloaded = 0;
|
|
item.bytes_total = *bytes_total;
|
|
item.attempt = Some(0);
|
|
item.max_attempts = Some(*max_attempts);
|
|
item.error = None;
|
|
item.request_url = None;
|
|
item.source = None;
|
|
} else {
|
|
let index = items.len();
|
|
items.push(DownloadItemSnapshot {
|
|
id: path.clone(),
|
|
name: path.clone(),
|
|
project_id: None,
|
|
version_id: None,
|
|
status: DownloadItemStatus::Queued,
|
|
bytes_downloaded: 0,
|
|
bytes_total: *bytes_total,
|
|
attempt: Some(0),
|
|
max_attempts: Some(*max_attempts),
|
|
error: None,
|
|
manual_url: None,
|
|
request_url: None,
|
|
source: None,
|
|
});
|
|
indices.insert(path.clone(), index);
|
|
}
|
|
}
|
|
InstallJobEventKind::ContentFileBrowserOptions {
|
|
path,
|
|
urls,
|
|
} => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.manual_url = urls.first().cloned();
|
|
}
|
|
}
|
|
InstallJobEventKind::ContentFileVerificationStarted {
|
|
path,
|
|
} => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Verifying;
|
|
item.error = None;
|
|
}
|
|
}
|
|
InstallJobEventKind::ContentFileWritingStarted { path } => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Writing;
|
|
item.error = None;
|
|
}
|
|
}
|
|
InstallJobEventKind::ContentFileRecovered { path, bytes } => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Completed;
|
|
item.bytes_downloaded = *bytes;
|
|
item.bytes_total = item.bytes_total.or(Some(*bytes));
|
|
item.error = None;
|
|
}
|
|
}
|
|
InstallJobEventKind::ContentFileDownloadAttempt {
|
|
path,
|
|
bytes_total,
|
|
attempt,
|
|
max_attempts,
|
|
} => {
|
|
content_items.insert(path.clone());
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Queued;
|
|
item.bytes_downloaded = 0;
|
|
item.bytes_total = *bytes_total;
|
|
item.attempt = Some(*attempt);
|
|
item.max_attempts = Some(*max_attempts);
|
|
item.error = None;
|
|
item.request_url = None;
|
|
item.source = None;
|
|
} else {
|
|
let index = items.len();
|
|
items.push(DownloadItemSnapshot {
|
|
id: path.clone(),
|
|
name: path.clone(),
|
|
project_id: None,
|
|
version_id: None,
|
|
status: DownloadItemStatus::Queued,
|
|
bytes_downloaded: 0,
|
|
bytes_total: *bytes_total,
|
|
attempt: Some(*attempt),
|
|
max_attempts: Some(*max_attempts),
|
|
error: None,
|
|
manual_url: None,
|
|
request_url: None,
|
|
source: None,
|
|
});
|
|
indices.insert(path.clone(), index);
|
|
}
|
|
}
|
|
InstallJobEventKind::DownloadRequestStarted {
|
|
path,
|
|
name,
|
|
url,
|
|
source,
|
|
bytes_total,
|
|
attempt,
|
|
max_attempts,
|
|
} => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Downloading;
|
|
item.bytes_total = item.bytes_total.or(*bytes_total);
|
|
item.attempt = Some(*attempt);
|
|
item.max_attempts = Some(*max_attempts);
|
|
item.error = None;
|
|
item.request_url = Some(url.clone());
|
|
item.source = Some(source.clone());
|
|
} else {
|
|
let index = items.len();
|
|
items.push(DownloadItemSnapshot {
|
|
id: path.clone(),
|
|
name: name.clone(),
|
|
project_id: None,
|
|
version_id: None,
|
|
status: DownloadItemStatus::Downloading,
|
|
bytes_downloaded: 0,
|
|
bytes_total: *bytes_total,
|
|
attempt: Some(*attempt),
|
|
max_attempts: Some(*max_attempts),
|
|
error: None,
|
|
manual_url: None,
|
|
request_url: Some(url.clone()),
|
|
source: Some(source.clone()),
|
|
});
|
|
indices.insert(path.clone(), index);
|
|
}
|
|
}
|
|
InstallJobEventKind::DownloadRequestFinished {
|
|
path,
|
|
bytes,
|
|
} => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
// Content downloads still need verification and DB
|
|
// registration. Standalone Java/Minecraft requests
|
|
// are final once the request itself finishes.
|
|
item.status = if content_items.contains(path) {
|
|
DownloadItemStatus::Verifying
|
|
} else {
|
|
DownloadItemStatus::Completed
|
|
};
|
|
item.bytes_downloaded = *bytes;
|
|
item.bytes_total = item.bytes_total.or(Some(*bytes));
|
|
}
|
|
}
|
|
InstallJobEventKind::DownloadRequestFailed { path } => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Failed;
|
|
}
|
|
}
|
|
InstallJobEventKind::ContentFileCompleted { path, bytes } => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Completed;
|
|
item.bytes_downloaded = *bytes;
|
|
item.bytes_total = Some(*bytes);
|
|
item.error = None;
|
|
} else {
|
|
let index = items.len();
|
|
items.push(DownloadItemSnapshot {
|
|
id: path.clone(),
|
|
name: path.clone(),
|
|
project_id: None,
|
|
version_id: None,
|
|
status: DownloadItemStatus::Completed,
|
|
bytes_downloaded: *bytes,
|
|
bytes_total: Some(*bytes),
|
|
attempt: None,
|
|
max_attempts: None,
|
|
error: None,
|
|
manual_url: None,
|
|
request_url: None,
|
|
source: None,
|
|
});
|
|
indices.insert(path.clone(), index);
|
|
}
|
|
}
|
|
InstallJobEventKind::ContentFileSkipped {
|
|
path,
|
|
reason,
|
|
project_id,
|
|
version_id,
|
|
manual_url,
|
|
} => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Skipped;
|
|
item.bytes_downloaded = 0;
|
|
item.project_id = project_id.clone();
|
|
item.version_id = version_id.clone();
|
|
item.error = Some(reason.clone());
|
|
item.manual_url = manual_url.clone();
|
|
} else {
|
|
let index = items.len();
|
|
items.push(DownloadItemSnapshot {
|
|
id: path.clone(),
|
|
name: path.clone(),
|
|
project_id: project_id.clone(),
|
|
version_id: version_id.clone(),
|
|
status: DownloadItemStatus::Skipped,
|
|
bytes_downloaded: 0,
|
|
bytes_total: None,
|
|
attempt: None,
|
|
max_attempts: None,
|
|
error: Some(reason.clone()),
|
|
manual_url: manual_url.clone(),
|
|
request_url: None,
|
|
source: None,
|
|
});
|
|
indices.insert(path.clone(), index);
|
|
}
|
|
}
|
|
InstallJobEventKind::ContentFileFailed {
|
|
path,
|
|
reason,
|
|
project_id,
|
|
version_id,
|
|
} => {
|
|
if let Some(item) = indices
|
|
.get(path)
|
|
.and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.status = DownloadItemStatus::Failed;
|
|
item.bytes_downloaded = 0;
|
|
item.project_id = project_id.clone();
|
|
item.version_id = version_id.clone();
|
|
item.error = Some(reason.clone());
|
|
} else {
|
|
let index = items.len();
|
|
items.push(DownloadItemSnapshot {
|
|
id: path.clone(),
|
|
name: path.clone(),
|
|
project_id: project_id.clone(),
|
|
version_id: version_id.clone(),
|
|
status: DownloadItemStatus::Failed,
|
|
bytes_downloaded: 0,
|
|
bytes_total: None,
|
|
attempt: None,
|
|
max_attempts: None,
|
|
error: Some(reason.clone()),
|
|
manual_url: None,
|
|
request_url: None,
|
|
source: None,
|
|
});
|
|
indices.insert(path.clone(), index);
|
|
}
|
|
}
|
|
InstallJobEventKind::JobCanceled { .. } => {
|
|
for item in &mut items {
|
|
if matches!(
|
|
item.status,
|
|
DownloadItemStatus::Queued
|
|
| DownloadItemStatus::WorkerStarted
|
|
| DownloadItemStatus::WaitingForResource
|
|
| DownloadItemStatus::Connecting
|
|
| DownloadItemStatus::Downloading
|
|
| DownloadItemStatus::Verifying
|
|
| DownloadItemStatus::Writing
|
|
| DownloadItemStatus::Metadata
|
|
| DownloadItemStatus::WaitingForDatabase
|
|
| DownloadItemStatus::Finalizing
|
|
) {
|
|
item.status = DownloadItemStatus::Canceled;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
items
|
|
}
|
|
|
|
/// Applies the live `active_downloads` overlay on top of an event-replay
|
|
/// snapshot, producing the final item list.
|
|
fn overlay_active_downloads(
|
|
&self,
|
|
mut items: Vec<DownloadItemSnapshot>,
|
|
) -> Vec<DownloadItemSnapshot> {
|
|
let mut indices = HashMap::<String, usize>::new();
|
|
for (index, item) in items.iter().enumerate() {
|
|
indices.insert(item.id.clone(), index);
|
|
}
|
|
let terminal = self.events.iter().rev().any(|event| {
|
|
matches!(
|
|
event.kind,
|
|
InstallJobEventKind::JobSucceeded { .. }
|
|
| InstallJobEventKind::JobCanceled { .. }
|
|
)
|
|
});
|
|
if !terminal {
|
|
for (id, active) in &self.active_downloads {
|
|
if let Some(item) =
|
|
indices.get(id).and_then(|&index| items.get_mut(index))
|
|
{
|
|
item.name = active.name.clone();
|
|
item.status = active.status;
|
|
item.bytes_downloaded = active.bytes_downloaded;
|
|
item.bytes_total = active.bytes_total;
|
|
item.attempt = Some(active.attempt);
|
|
item.max_attempts = Some(active.max_attempts);
|
|
item.error = None;
|
|
item.request_url = Some(active.url.clone());
|
|
item.source = Some(active.source.clone());
|
|
} else {
|
|
items.push(DownloadItemSnapshot {
|
|
id: id.clone(),
|
|
name: active.name.clone(),
|
|
project_id: None,
|
|
version_id: None,
|
|
status: active.status,
|
|
bytes_downloaded: active.bytes_downloaded,
|
|
bytes_total: active.bytes_total,
|
|
attempt: Some(active.attempt),
|
|
max_attempts: Some(active.max_attempts),
|
|
error: None,
|
|
manual_url: None,
|
|
request_url: Some(active.url.clone()),
|
|
source: Some(active.source.clone()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
items
|
|
}
|
|
|
|
pub fn download_items(&self) -> Vec<DownloadItemSnapshot> {
|
|
let cached = self
|
|
.download_items_cache
|
|
.lock()
|
|
.expect("download items cache lock poisoned")
|
|
.clone();
|
|
let from_events = match cached {
|
|
Some(items) => items,
|
|
None => {
|
|
let items = self.download_items_from_events();
|
|
*self
|
|
.download_items_cache
|
|
.lock()
|
|
.expect("download items cache lock poisoned") =
|
|
Some(items.clone());
|
|
items
|
|
}
|
|
};
|
|
self.overlay_active_downloads(from_events)
|
|
}
|
|
|
|
/// Replays `events` into the event-derived summary base (settled files,
|
|
/// byte totals, source, fallback count). This is the expensive part of
|
|
/// `download_summary` and its result is cached; live progress and speed
|
|
/// are applied by the caller.
|
|
fn summary_from_events(&self) -> DownloadJobSummary {
|
|
let mut summary = DownloadJobSummary::default();
|
|
let mut settled_content = HashMap::<String, u64>::new();
|
|
for event in &self.events {
|
|
match &event.kind {
|
|
InstallJobEventKind::ContentDownloadStarted {
|
|
files,
|
|
bytes,
|
|
} => {
|
|
summary.files_total = Some(*files);
|
|
summary.bytes_total = *bytes;
|
|
}
|
|
|
|
InstallJobEventKind::ContentFileCompleted { path, bytes }
|
|
| InstallJobEventKind::ContentFileRecovered { path, bytes } => {
|
|
// 同一个文件在暂停/恢复后可能再次产生 settled 事件。
|
|
// 按 path 保存最终状态,避免重复累计。
|
|
settled_content.insert(path.clone(), *bytes);
|
|
}
|
|
|
|
InstallJobEventKind::ContentFileSkipped { path, .. }
|
|
| InstallJobEventKind::ContentFileFailed { path, .. } => {
|
|
// Failed / Skipped 算作已处理,但不贡献完成字节。
|
|
// 如果之后恢复成功,同一个 path 会被 Recovered 覆盖成真实大小。
|
|
settled_content.insert(path.clone(), 0);
|
|
}
|
|
|
|
InstallJobEventKind::DownloadMetrics {
|
|
source,
|
|
fallback_count,
|
|
} => {
|
|
summary.source = Some(source.clone());
|
|
summary.fallback_count =
|
|
summary.fallback_count.saturating_add(*fallback_count);
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
summary.files_completed = settled_content.len() as u64;
|
|
summary.bytes_downloaded = settled_content
|
|
.values()
|
|
.copied()
|
|
.fold(0_u64, u64::saturating_add);
|
|
summary
|
|
}
|
|
|
|
pub fn download_summary(&self) -> DownloadJobSummary {
|
|
let cached = self
|
|
.summary_cache
|
|
.lock()
|
|
.expect("download summary cache lock poisoned")
|
|
.clone();
|
|
let mut summary = match cached {
|
|
Some(summary) => summary,
|
|
None => {
|
|
let summary = self.summary_from_events();
|
|
*self
|
|
.summary_cache
|
|
.lock()
|
|
.expect("download summary cache lock poisoned") =
|
|
Some(summary.clone());
|
|
summary
|
|
}
|
|
};
|
|
if let Some(progress) = &self.progress.progress {
|
|
if self.progress.phase == InstallPhaseId::DownloadingContent {
|
|
summary.files_completed = progress.current;
|
|
summary.files_total = Some(progress.total);
|
|
|
|
if let Some(bytes) = &progress.secondary {
|
|
summary.bytes_downloaded = bytes.current;
|
|
summary.bytes_total = Some(bytes.total);
|
|
}
|
|
} else if self.progress.phase
|
|
== InstallPhaseId::DownloadingMinecraft
|
|
|| self.progress.phase == InstallPhaseId::DownloadingPackFile
|
|
|| matches!(
|
|
&self.progress.details,
|
|
InstallPhaseDetails::Java {
|
|
step: InstallJavaStep::Downloading,
|
|
..
|
|
}
|
|
)
|
|
{
|
|
summary.bytes_downloaded = progress.current;
|
|
summary.bytes_total = Some(progress.total);
|
|
}
|
|
}
|
|
let actively_downloading = matches!(
|
|
self.progress.phase,
|
|
InstallPhaseId::DownloadingPackFile
|
|
| InstallPhaseId::DownloadingContent
|
|
| InstallPhaseId::DownloadingMinecraft
|
|
) || matches!(
|
|
&self.progress.details,
|
|
InstallPhaseDetails::Java {
|
|
step: InstallJavaStep::Downloading,
|
|
..
|
|
}
|
|
);
|
|
let active_speed = self
|
|
.active_downloads
|
|
.values()
|
|
.filter(|download| {
|
|
Utc::now()
|
|
.signed_duration_since(download.last_progress_at)
|
|
.num_milliseconds()
|
|
< 3_000
|
|
})
|
|
.filter_map(|download| download.speed_bytes_per_second)
|
|
.fold(0_u64, u64::saturating_add);
|
|
if actively_downloading && active_speed > 0 {
|
|
summary.speed_bytes_per_second = Some(active_speed);
|
|
summary.eta_seconds = summary.bytes_total.and_then(|total| {
|
|
total
|
|
.saturating_sub(summary.bytes_downloaded)
|
|
.checked_add(active_speed - 1)
|
|
.and_then(|remaining| remaining.checked_div(active_speed))
|
|
});
|
|
}
|
|
summary
|
|
}
|
|
}
|