1441 lines
48 KiB
Rust
1441 lines
48 KiB
Rust
use super::model::{
|
|
ActiveDownloadState, DownloadItemStatus, InstallContinuationState,
|
|
InstallErrorContext, InstallJobEventKind, InstallJobSnapshot,
|
|
InstallJobState, InstallParallelProgress, InstallPauseReason,
|
|
InstallPhaseDetails, InstallPhaseId, InstallProgress, InstallRollbackState,
|
|
InstanceUpgradeResult, MissingModpackContentState,
|
|
};
|
|
use super::store;
|
|
use chrono::Utc;
|
|
use std::collections::HashSet;
|
|
use std::sync::{Arc, LazyLock, Weak};
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::Mutex;
|
|
use tokio_util::sync::CancellationToken;
|
|
use uuid::Uuid;
|
|
|
|
const PROGRESS_PERSIST_INTERVAL: Duration = Duration::from_millis(500);
|
|
const CONTENT_PROGRESS_PERSIST_STEPS: u64 = 25;
|
|
const LIVE_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(250);
|
|
const LIVE_PROGRESS_PERSIST_INTERVAL: Duration = Duration::from_secs(3);
|
|
const LIVE_PROGRESS_MIN_BYTES: u64 = 256 * 1024;
|
|
|
|
static REPORTER_STATES: LazyLock<
|
|
dashmap::DashMap<Uuid, Weak<Mutex<InstallProgressReporterState>>>,
|
|
> = LazyLock::new(dashmap::DashMap::new);
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct InstallProgressReporter {
|
|
job_id: Uuid,
|
|
state: Arc<Mutex<InstallProgressReporterState>>,
|
|
/// When set, `update`/`update_with_events` write to the parallel track
|
|
/// (`InstallProgressState.parallel`) instead of the main phase. Used by
|
|
/// the modpack installer to report the concurrent Minecraft core download
|
|
/// while content installation remains the main phase.
|
|
parallel_output: bool,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct InstallProgressReporterState {
|
|
job: InstallJobState,
|
|
last_persisted_at: Instant,
|
|
last_persisted_progress: Option<(InstallPhaseId, u64)>,
|
|
initialized_from_store: bool,
|
|
postponed_java_versions: HashSet<u32>,
|
|
last_live_emit_at: Instant,
|
|
last_live_persist_at: Instant,
|
|
/// Paths with a pending stalled-download check task, so at most one
|
|
/// delayed check is scheduled per active download at a time.
|
|
pending_stall_checks: HashSet<String>,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
enum DownloadRequestUpdate {
|
|
Started {
|
|
job_id: Uuid,
|
|
id: String,
|
|
name: String,
|
|
url: String,
|
|
source: String,
|
|
bytes_total: Option<u64>,
|
|
attempt: u32,
|
|
max_attempts: u32,
|
|
},
|
|
Progress {
|
|
job_id: Uuid,
|
|
id: String,
|
|
bytes: u64,
|
|
status: DownloadItemStatus,
|
|
speed_bytes_per_second: Option<u64>,
|
|
eta_seconds: Option<u64>,
|
|
},
|
|
Finished {
|
|
job_id: Uuid,
|
|
id: String,
|
|
bytes: u64,
|
|
},
|
|
Failed {
|
|
job_id: Uuid,
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
impl InstallProgressReporter {
|
|
pub(crate) fn reset_job(job_id: Uuid) {
|
|
REPORTER_STATES.remove(&job_id);
|
|
}
|
|
|
|
pub(crate) fn cancellation_token(&self) -> CancellationToken {
|
|
crate::State::get_if_initialized()
|
|
.and_then(|state| {
|
|
state
|
|
.install_job_cancellations
|
|
.get(&self.job_id)
|
|
.map(|entry| entry.value().clone())
|
|
})
|
|
.unwrap_or_else(CancellationToken::new)
|
|
}
|
|
|
|
pub(crate) fn job_id(&self) -> Uuid {
|
|
self.job_id
|
|
}
|
|
|
|
pub fn new(job_id: Uuid, mut state: InstallJobState) -> Self {
|
|
state.compact_transient_download_events();
|
|
let shared_state = match REPORTER_STATES.entry(job_id) {
|
|
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
|
|
if let Some(state) = entry.get().upgrade() {
|
|
state
|
|
} else {
|
|
let state =
|
|
Arc::new(Mutex::new(InstallProgressReporterState {
|
|
job: state,
|
|
last_persisted_at: Instant::now(),
|
|
last_persisted_progress: None,
|
|
initialized_from_store: false,
|
|
postponed_java_versions: HashSet::new(),
|
|
last_live_emit_at: Instant::now(),
|
|
last_live_persist_at: Instant::now(),
|
|
pending_stall_checks: HashSet::new(),
|
|
}));
|
|
entry.insert(Arc::downgrade(&state));
|
|
state
|
|
}
|
|
}
|
|
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
|
let state =
|
|
Arc::new(Mutex::new(InstallProgressReporterState {
|
|
job: state,
|
|
last_persisted_at: Instant::now(),
|
|
last_persisted_progress: None,
|
|
initialized_from_store: false,
|
|
postponed_java_versions: HashSet::new(),
|
|
last_live_emit_at: Instant::now(),
|
|
last_live_persist_at: Instant::now(),
|
|
pending_stall_checks: HashSet::new(),
|
|
}));
|
|
entry.insert(Arc::downgrade(&state));
|
|
state
|
|
}
|
|
};
|
|
Self {
|
|
job_id,
|
|
state: shared_state,
|
|
parallel_output: false,
|
|
}
|
|
}
|
|
|
|
/// Returns a reporter clone whose phase updates are routed to the
|
|
/// parallel track, leaving the main phase untouched. The underlying job
|
|
/// state is shared, so both tracks still persist to the same job.
|
|
pub fn with_parallel_output(mut self) -> Self {
|
|
self.parallel_output = true;
|
|
self
|
|
}
|
|
|
|
pub async fn update(
|
|
&self,
|
|
phase: InstallPhaseId,
|
|
progress: Option<InstallProgress>,
|
|
details: InstallPhaseDetails,
|
|
) -> crate::Result<()> {
|
|
if self.parallel_output {
|
|
self.update_parallel(phase, progress, details).await
|
|
} else {
|
|
self.update_main(phase, progress, details).await
|
|
}
|
|
}
|
|
|
|
async fn update_main(
|
|
&self,
|
|
phase: InstallPhaseId,
|
|
progress: Option<InstallProgress>,
|
|
details: InstallPhaseDetails,
|
|
) -> crate::Result<()> {
|
|
self.update_with_events(phase, progress, details, Vec::new())
|
|
.await
|
|
}
|
|
|
|
/// Updates the parallel track while the main phase keeps its own
|
|
/// progress. The parallel track lives on `InstallProgressState` and is
|
|
/// preserved across main-phase progress updates.
|
|
async fn update_parallel(
|
|
&self,
|
|
phase: InstallPhaseId,
|
|
progress: Option<InstallProgress>,
|
|
details: InstallPhaseDetails,
|
|
) -> crate::Result<()> {
|
|
let app_state = match crate::State::get().await {
|
|
Ok(app_state) => app_state,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "Failed to access install progress store");
|
|
return Ok(());
|
|
}
|
|
};
|
|
let mut state = self.state.lock().await;
|
|
if let Err(error) = self.sync_latest(&mut state, &app_state).await {
|
|
tracing::warn!(%error, "Failed to load install progress state");
|
|
return Ok(());
|
|
}
|
|
let (current, total) = match &progress {
|
|
Some(progress) => (progress.current, progress.total),
|
|
None => state
|
|
.job
|
|
.progress
|
|
.parallel
|
|
.as_ref()
|
|
.map(|parallel| (parallel.current, parallel.total))
|
|
.unwrap_or((0, 0)),
|
|
};
|
|
let previous = &state.job.progress.parallel;
|
|
let phase_changed = previous
|
|
.as_ref()
|
|
.is_none_or(|parallel| parallel.phase != phase);
|
|
let total_changed = previous
|
|
.as_ref()
|
|
.is_none_or(|parallel| parallel.total != total);
|
|
let enough_progress = previous
|
|
.as_ref()
|
|
.map(|parallel| {
|
|
current.saturating_sub(parallel.current)
|
|
>= (parallel.total / 200)
|
|
.max(CONTENT_PROGRESS_PERSIST_STEPS)
|
|
})
|
|
.unwrap_or(true);
|
|
state.job.progress.parallel = Some(InstallParallelProgress {
|
|
phase,
|
|
current,
|
|
total,
|
|
details,
|
|
});
|
|
if !(phase_changed || total_changed || enough_progress)
|
|
&& state.last_persisted_at.elapsed() < PROGRESS_PERSIST_INTERVAL
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
let json = serde_json::to_string(&state.job)?;
|
|
let provider = state.job.provider().as_str().to_string();
|
|
let summary = state.job.download_summary();
|
|
drop(state);
|
|
let record = match store::update_progress_state(
|
|
self.job_id,
|
|
&json,
|
|
&provider,
|
|
&summary,
|
|
&app_state,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => store::get_required(self.job_id, &app_state).await,
|
|
Err(error) => Err(error),
|
|
};
|
|
let record = match record {
|
|
Ok(record) => record,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "Failed to persist parallel install progress");
|
|
return Ok(());
|
|
}
|
|
};
|
|
if let Ok(mut state) = self.state.try_lock() {
|
|
state.mark_persisted();
|
|
}
|
|
if let Err(error) = emit_install_job(&record.snapshot()).await {
|
|
tracing::warn!(%error, "Failed to emit parallel install progress");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
pub async fn is_java_download_postponed(&self, version: u32) -> bool {
|
|
self.state
|
|
.lock()
|
|
.await
|
|
.postponed_java_versions
|
|
.contains(&version)
|
|
}
|
|
|
|
pub async fn postpone_java_download(&self, version: u32) {
|
|
self.state
|
|
.lock()
|
|
.await
|
|
.postponed_java_versions
|
|
.insert(version);
|
|
}
|
|
|
|
async fn sync_latest(
|
|
&self,
|
|
state: &mut InstallProgressReporterState,
|
|
app_state: &crate::State,
|
|
) -> crate::Result<()> {
|
|
if !state.initialized_from_store {
|
|
state.job =
|
|
store::get_required(self.job_id, app_state).await?.state;
|
|
state.job.compact_transient_download_events();
|
|
state.initialized_from_store = true;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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;
|
|
if let Some(app_state) = &app_state {
|
|
self.sync_latest(&mut state, app_state).await?;
|
|
}
|
|
state.job.set_context(context);
|
|
|
|
let Some(app_state) = app_state else {
|
|
return Ok(());
|
|
};
|
|
|
|
let record = match store::update_state(
|
|
self.job_id,
|
|
&state.job,
|
|
&app_state,
|
|
)
|
|
.await
|
|
{
|
|
Ok(record) => record,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "Failed to persist install context");
|
|
return Ok(());
|
|
}
|
|
};
|
|
state.mark_persisted();
|
|
if let Err(error) = emit_install_job(&record.snapshot()).await {
|
|
tracing::warn!(%error, "Failed to emit install context");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn persist(&self) -> crate::Result<InstallJobSnapshot> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).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 current_state(&self) -> crate::Result<InstallJobState> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
Ok(state.job.clone())
|
|
}
|
|
|
|
pub async fn set_continuation(
|
|
&self,
|
|
continuation: Option<InstallContinuationState>,
|
|
) -> crate::Result<()> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
state.job.continuation = continuation;
|
|
let record =
|
|
store::update_state(self.job_id, &state.job, &app_state).await?;
|
|
state.mark_persisted();
|
|
emit_install_job(&record.snapshot()).await
|
|
}
|
|
|
|
pub async fn set_missing_content(
|
|
&self,
|
|
missing_content: Option<MissingModpackContentState>,
|
|
) -> crate::Result<()> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
state.job.missing_content = missing_content;
|
|
let record =
|
|
store::update_state(self.job_id, &state.job, &app_state).await?;
|
|
state.mark_persisted();
|
|
emit_install_job(&record.snapshot()).await
|
|
}
|
|
|
|
pub async fn set_upgrade_result(
|
|
&self,
|
|
result: InstanceUpgradeResult,
|
|
) -> crate::Result<()> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
state.job.upgrade_result = Some(result);
|
|
let record =
|
|
store::update_state(self.job_id, &state.job, &app_state).await?;
|
|
state.mark_persisted();
|
|
emit_install_job(&record.snapshot()).await
|
|
}
|
|
|
|
pub async fn record_events(
|
|
&self,
|
|
events: Vec<InstallJobEventKind>,
|
|
) -> crate::Result<InstallJobSnapshot> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
let refresh_missing_reason = events.iter().any(|event| {
|
|
matches!(
|
|
event,
|
|
InstallJobEventKind::ContentFileRecovered { .. }
|
|
| InstallJobEventKind::ContentFileFailed { .. }
|
|
)
|
|
});
|
|
for event in events {
|
|
state.job.record_event(event);
|
|
}
|
|
if refresh_missing_reason {
|
|
refresh_missing_pause_reason(&mut state.job);
|
|
}
|
|
// Serialize under the lock; the DB write runs without holding the
|
|
// reporter mutex so per-file completion events never serialize on it.
|
|
let json = serde_json::to_string(&state.job)?;
|
|
let provider = state.job.provider().as_str().to_string();
|
|
let summary = state.job.download_summary();
|
|
drop(state);
|
|
let record = store::update_state_with_progress_columns(
|
|
self.job_id,
|
|
&json,
|
|
&provider,
|
|
&summary,
|
|
&app_state,
|
|
)
|
|
.await?;
|
|
if let Ok(mut state) = self.state.try_lock() {
|
|
state.mark_persisted();
|
|
}
|
|
let snapshot = record.snapshot();
|
|
emit_install_job(&snapshot).await?;
|
|
Ok(snapshot)
|
|
}
|
|
|
|
pub(crate) async fn set_rollback(
|
|
&self,
|
|
rollback: Option<InstallRollbackState>,
|
|
) -> crate::Result<()> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
state.job.rollback = rollback;
|
|
let record =
|
|
store::update_state(self.job_id, &state.job, &app_state).await?;
|
|
state.mark_persisted();
|
|
emit_install_job(&record.snapshot()).await
|
|
}
|
|
|
|
pub async fn track_rollback_paths(
|
|
&self,
|
|
paths: Vec<String>,
|
|
) -> crate::Result<()> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
let Some(snapshot) = state
|
|
.job
|
|
.rollback
|
|
.as_mut()
|
|
.and_then(|rollback| rollback.content.as_mut())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
for path in paths {
|
|
path_util::SafeRelativeUtf8UnixPathBuf::try_from(path.clone())?;
|
|
if !snapshot.replacement_paths.contains(&path) {
|
|
snapshot.replacement_paths.push(path);
|
|
}
|
|
}
|
|
snapshot.replacement_paths.sort();
|
|
let record =
|
|
store::update_state(self.job_id, &state.job, &app_state).await?;
|
|
state.mark_persisted();
|
|
emit_install_job(&record.snapshot()).await
|
|
}
|
|
|
|
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 record_download_metrics(
|
|
&self,
|
|
source: impl Into<String>,
|
|
fallback_count: u64,
|
|
) -> crate::Result<()> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
|
|
state
|
|
.job
|
|
.record_event(InstallJobEventKind::DownloadMetrics {
|
|
source: source.into(),
|
|
fallback_count,
|
|
});
|
|
let record = match store::update_state(
|
|
self.job_id,
|
|
&state.job,
|
|
&app_state,
|
|
)
|
|
.await
|
|
{
|
|
Ok(record) => record,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "Failed to persist download metrics");
|
|
return Ok(());
|
|
}
|
|
};
|
|
state.mark_persisted();
|
|
if let Err(error) = emit_install_job(&record.snapshot()).await {
|
|
tracing::warn!(%error, "Failed to emit download metrics");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn record_download_request(
|
|
&self,
|
|
path: impl Into<String>,
|
|
name: impl Into<String>,
|
|
url: impl Into<String>,
|
|
source: impl Into<String>,
|
|
bytes_total: Option<u64>,
|
|
attempt: u32,
|
|
max_attempts: u32,
|
|
) -> crate::Result<()> {
|
|
let path = path.into();
|
|
let name = name.into();
|
|
let url = url.into();
|
|
let source = source.into();
|
|
self.record_live_event(
|
|
InstallJobEventKind::DownloadRequestStarted {
|
|
path: path.clone(),
|
|
name: name.clone(),
|
|
url: url.clone(),
|
|
source: source.clone(),
|
|
bytes_total,
|
|
attempt,
|
|
max_attempts,
|
|
},
|
|
DownloadRequestUpdate::Started {
|
|
job_id: self.job_id,
|
|
id: path,
|
|
name,
|
|
url,
|
|
source,
|
|
bytes_total,
|
|
attempt,
|
|
max_attempts,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn record_download_progress(
|
|
&self,
|
|
path: impl Into<String>,
|
|
bytes: u64,
|
|
bytes_total: u64,
|
|
) -> crate::Result<()> {
|
|
let path = path.into();
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
let now = Utc::now();
|
|
let emit_too_soon =
|
|
state.last_live_emit_at.elapsed() < LIVE_PROGRESS_EMIT_INTERVAL;
|
|
let Some(active) = state.job.active_downloads.get_mut(&path) else {
|
|
return Ok(());
|
|
};
|
|
let previous_bytes = active.bytes_downloaded;
|
|
if bytes < previous_bytes {
|
|
active.speed_bytes_per_second = None;
|
|
active.speed_sample_started_at = now;
|
|
active.speed_sample_started_bytes = bytes;
|
|
} else if bytes > previous_bytes {
|
|
active.last_progress_at = now;
|
|
let sample_elapsed_ms = now
|
|
.signed_duration_since(active.speed_sample_started_at)
|
|
.num_milliseconds()
|
|
.max(1) as u64;
|
|
if sample_elapsed_ms >= 250 {
|
|
let sample = bytes
|
|
.saturating_sub(active.speed_sample_started_bytes)
|
|
.saturating_mul(1_000)
|
|
.checked_div(sample_elapsed_ms)
|
|
.unwrap_or(0);
|
|
let new_speed = match active.speed_bytes_per_second {
|
|
Some(previous) if sample > previous => {
|
|
previous + (((sample - previous) as f64) * 0.5) as u64
|
|
}
|
|
Some(previous) => {
|
|
(((previous as f64) * 0.95) + ((sample as f64) * 0.05))
|
|
as u64
|
|
}
|
|
None => sample,
|
|
};
|
|
active.speed_bytes_per_second = Some(new_speed);
|
|
active.speed_sample_started_at = now;
|
|
active.speed_sample_started_bytes = bytes;
|
|
}
|
|
}
|
|
active.bytes_downloaded = bytes;
|
|
active.bytes_total = Some(bytes_total);
|
|
active.status = DownloadItemStatus::Downloading;
|
|
|
|
let threshold = LIVE_PROGRESS_MIN_BYTES.max(bytes_total / 200);
|
|
if bytes.saturating_sub(active.last_reported_bytes) < threshold
|
|
|| emit_too_soon
|
|
{
|
|
return Ok(());
|
|
}
|
|
active.last_reported_bytes = bytes;
|
|
state.last_live_emit_at = Instant::now();
|
|
let (speed_bytes_per_second, eta_seconds) =
|
|
live_download_metrics(&state.job);
|
|
let should_persist = state.last_live_persist_at.elapsed()
|
|
>= LIVE_PROGRESS_PERSIST_INTERVAL;
|
|
let schedule_stall_check =
|
|
state.pending_stall_checks.insert(path.clone());
|
|
// Serialize and summarize under the lock (CPU only); the DB write
|
|
// below runs without holding the reporter mutex so progress
|
|
// callbacks from other files never block on the transaction.
|
|
let persisted = if should_persist {
|
|
state.last_live_persist_at = Instant::now();
|
|
Some((
|
|
serde_json::to_string(&state.job)?,
|
|
state.job.provider().as_str().to_string(),
|
|
state.job.download_summary(),
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
drop(state);
|
|
if let Some((json, provider, summary)) = persisted {
|
|
if let Err(error) = store::update_progress_state(
|
|
self.job_id,
|
|
&json,
|
|
&provider,
|
|
&summary,
|
|
&app_state,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(%error, "Failed to persist live download progress");
|
|
}
|
|
}
|
|
emit_download_request_update(&DownloadRequestUpdate::Progress {
|
|
job_id: self.job_id,
|
|
id: path.clone(),
|
|
bytes,
|
|
status: DownloadItemStatus::Downloading,
|
|
speed_bytes_per_second,
|
|
eta_seconds,
|
|
})
|
|
.await?;
|
|
|
|
if schedule_stall_check {
|
|
let reporter = self.clone();
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
if let Err(error) = reporter
|
|
.record_download_stalled_if_unchanged(path.clone(), bytes)
|
|
.await
|
|
{
|
|
tracing::warn!(%error, "Failed to record stalled download");
|
|
}
|
|
if let Ok(mut state) = reporter.state.try_lock() {
|
|
state.pending_stall_checks.remove(&path);
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn record_download_stage(
|
|
&self,
|
|
path: impl Into<String>,
|
|
status: DownloadItemStatus,
|
|
) -> crate::Result<()> {
|
|
let path = path.into();
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
let Some(active) = state.job.active_downloads.get_mut(&path) else {
|
|
return Ok(());
|
|
};
|
|
active.status = status;
|
|
let bytes = active.bytes_downloaded;
|
|
let (speed_bytes_per_second, eta_seconds) =
|
|
live_download_metrics(&state.job);
|
|
state.last_live_emit_at = Instant::now();
|
|
drop(state);
|
|
emit_download_request_update(&DownloadRequestUpdate::Progress {
|
|
job_id: self.job_id,
|
|
id: path,
|
|
bytes,
|
|
status,
|
|
speed_bytes_per_second,
|
|
eta_seconds,
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn record_download_request_finished(
|
|
&self,
|
|
path: impl Into<String>,
|
|
bytes: u64,
|
|
) -> crate::Result<()> {
|
|
let path = path.into();
|
|
self.record_live_event(
|
|
InstallJobEventKind::DownloadRequestFinished {
|
|
path: path.clone(),
|
|
bytes,
|
|
},
|
|
DownloadRequestUpdate::Finished {
|
|
job_id: self.job_id,
|
|
id: path,
|
|
bytes,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn record_download_request_failed(
|
|
&self,
|
|
path: impl Into<String>,
|
|
) -> crate::Result<()> {
|
|
let path = path.into();
|
|
self.record_live_event(
|
|
InstallJobEventKind::DownloadRequestFailed { path: path.clone() },
|
|
DownloadRequestUpdate::Failed {
|
|
job_id: self.job_id,
|
|
id: path,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn record_download_stalled_if_unchanged(
|
|
&self,
|
|
path: String,
|
|
bytes: u64,
|
|
) -> crate::Result<()> {
|
|
let mut state = self.state.lock().await;
|
|
let Some(active) = state.job.active_downloads.get_mut(&path) else {
|
|
return Ok(());
|
|
};
|
|
if active.bytes_downloaded != bytes
|
|
|| Utc::now()
|
|
.signed_duration_since(active.last_progress_at)
|
|
.num_milliseconds()
|
|
< 3_000
|
|
{
|
|
return Ok(());
|
|
}
|
|
active.speed_bytes_per_second = None;
|
|
let status = active.status;
|
|
let (speed_bytes_per_second, eta_seconds) =
|
|
live_download_metrics(&state.job);
|
|
drop(state);
|
|
emit_download_request_update(&DownloadRequestUpdate::Progress {
|
|
job_id: self.job_id,
|
|
id: path,
|
|
bytes,
|
|
status,
|
|
speed_bytes_per_second,
|
|
eta_seconds,
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn record_live_event(
|
|
&self,
|
|
event: InstallJobEventKind,
|
|
update: DownloadRequestUpdate,
|
|
) -> crate::Result<()> {
|
|
let app_state = crate::State::get().await?;
|
|
let mut state = self.state.lock().await;
|
|
self.sync_latest(&mut state, &app_state).await?;
|
|
match &event {
|
|
InstallJobEventKind::DownloadRequestStarted {
|
|
path,
|
|
name,
|
|
url,
|
|
source,
|
|
bytes_total,
|
|
attempt,
|
|
max_attempts,
|
|
} => {
|
|
state.job.active_downloads.insert(
|
|
path.clone(),
|
|
ActiveDownloadState {
|
|
name: name.clone(),
|
|
url: url.clone(),
|
|
source: source.clone(),
|
|
bytes_downloaded: 0,
|
|
bytes_total: *bytes_total,
|
|
attempt: *attempt,
|
|
max_attempts: *max_attempts,
|
|
status: DownloadItemStatus::Downloading,
|
|
last_reported_bytes: 0,
|
|
last_progress_at: Utc::now(),
|
|
speed_bytes_per_second: None,
|
|
speed_sample_started_at: Utc::now(),
|
|
speed_sample_started_bytes: 0,
|
|
},
|
|
);
|
|
}
|
|
InstallJobEventKind::DownloadRequestFinished { path, .. }
|
|
| InstallJobEventKind::DownloadRequestFailed { path } => {
|
|
state.job.active_downloads.remove(path);
|
|
}
|
|
_ => {}
|
|
}
|
|
drop(state);
|
|
emit_download_request_update(&update).await
|
|
}
|
|
|
|
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 = match crate::State::get().await {
|
|
Ok(app_state) => app_state,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "Failed to access install progress store");
|
|
return Ok(());
|
|
}
|
|
};
|
|
let mut state = self.state.lock().await;
|
|
if let Err(error) = self.sync_latest(&mut state, &app_state).await {
|
|
tracing::warn!(%error, "Failed to load install progress state");
|
|
return Ok(());
|
|
}
|
|
let phase_started = state.job.progress.phase != phase
|
|
|| matches!(
|
|
&state.job.progress.details,
|
|
InstallPhaseDetails::Empty
|
|
) && !matches!(&details, InstallPhaseDetails::Empty);
|
|
let progress_counter_started = state.job.progress.phase == phase
|
|
&& match (&state.job.progress.progress, &progress) {
|
|
(None, Some(_)) => true,
|
|
(Some(old), Some(new)) => old.total != new.total,
|
|
_ => false,
|
|
};
|
|
state.job.set_progress(phase, progress, details);
|
|
for event in events {
|
|
state.job.record_event(event);
|
|
}
|
|
|
|
if !state.should_persist(phase_started || progress_counter_started) {
|
|
return Ok(());
|
|
}
|
|
|
|
// Serialize and summarize under the lock (CPU only); the DB write
|
|
// below runs without holding the reporter mutex.
|
|
let json = serde_json::to_string(&state.job)?;
|
|
let provider = state.job.provider().as_str().to_string();
|
|
let summary = state.job.download_summary();
|
|
drop(state);
|
|
let record = match store::update_progress_state(
|
|
self.job_id,
|
|
&json,
|
|
&provider,
|
|
&summary,
|
|
&app_state,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => store::get_required(self.job_id, &app_state).await,
|
|
Err(error) => Err(error),
|
|
};
|
|
let record = match record {
|
|
Ok(record) => record,
|
|
Err(error) => {
|
|
tracing::warn!(%error, "Failed to persist install progress");
|
|
return Ok(());
|
|
}
|
|
};
|
|
if let Ok(mut state) = self.state.try_lock() {
|
|
state.mark_persisted();
|
|
}
|
|
if let Err(error) = emit_install_job(&record.snapshot()).await {
|
|
tracing::warn!(%error, "Failed to emit install progress");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn refresh_missing_pause_reason(job: &mut InstallJobState) {
|
|
if !matches!(
|
|
job.pause_reason,
|
|
Some(InstallPauseReason::MissingRequiredContent { .. })
|
|
) {
|
|
return;
|
|
}
|
|
let items = job.download_items();
|
|
let Some(content) = &job.missing_content else {
|
|
let paths = items
|
|
.iter()
|
|
.filter(|item| {
|
|
item.status == DownloadItemStatus::Skipped
|
|
&& item.manual_url.is_some()
|
|
&& item.project_id.is_some()
|
|
&& item.version_id.is_some()
|
|
})
|
|
.map(|item| item.id.clone())
|
|
.collect::<Vec<_>>();
|
|
job.pause_reason = Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: paths.len() as u64,
|
|
paths,
|
|
});
|
|
return;
|
|
};
|
|
let paths = content
|
|
.files
|
|
.iter()
|
|
.filter(|file| {
|
|
items.iter().any(|item| {
|
|
item.id == file.item_id
|
|
&& item.status == DownloadItemStatus::Failed
|
|
})
|
|
})
|
|
.map(|file| file.item_id.clone())
|
|
.collect::<Vec<_>>();
|
|
job.pause_reason = Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: paths.len() as u64,
|
|
paths,
|
|
});
|
|
}
|
|
|
|
impl InstallProgressReporterState {
|
|
fn should_persist(&self, state_transition: bool) -> bool {
|
|
if state_transition {
|
|
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));
|
|
}
|
|
}
|
|
|
|
fn live_download_metrics(job: &InstallJobState) -> (Option<u64>, Option<u64>) {
|
|
let summary = job.download_summary();
|
|
(summary.speed_bytes_per_second, summary.eta_seconds)
|
|
}
|
|
|
|
#[allow(unused_variables)]
|
|
pub async fn emit_install_job(
|
|
snapshot: &InstallJobSnapshot,
|
|
) -> crate::Result<()> {
|
|
#[cfg(feature = "tauri")]
|
|
{
|
|
use tauri::Emitter;
|
|
|
|
let event_state = crate::EventState::get()?;
|
|
event_state
|
|
.app
|
|
.emit("install_job", snapshot)
|
|
.map_err(crate::event::EventError::from)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(unused_variables)]
|
|
async fn emit_download_request_update(
|
|
update: &DownloadRequestUpdate,
|
|
) -> crate::Result<()> {
|
|
#[cfg(feature = "tauri")]
|
|
{
|
|
use tauri::Emitter;
|
|
|
|
let event_state = crate::EventState::get()?;
|
|
event_state
|
|
.app
|
|
.emit("download_request", update)
|
|
.map_err(crate::event::EventError::from)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::api::pack::install_from::CreatePackLocation;
|
|
use crate::install::InstallRequest;
|
|
#[cfg(not(feature = "tauri"))]
|
|
use crate::install::model::InstallProgress;
|
|
#[cfg(not(feature = "tauri"))]
|
|
use crate::install::model::InstallProgressSecondary;
|
|
use crate::install::model::{
|
|
InstallJobEventKind, InstallJobExecutionMode, InstallJobKind,
|
|
InstallJobStatus, InstallPauseReason, InstallPhaseDetails,
|
|
InstallPhaseId, MissingModpackContentState,
|
|
};
|
|
use crate::state::{InstanceLink, ModLoader};
|
|
|
|
#[cfg(not(feature = "tauri"))]
|
|
fn minecraft_details() -> InstallPhaseDetails {
|
|
InstallPhaseDetails::Minecraft {
|
|
game_version: "1.21.1".to_string(),
|
|
loader: ModLoader::Vanilla,
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "tauri"))]
|
|
fn minecraft_progress(current: u64, total: u64) -> InstallProgress {
|
|
InstallProgress {
|
|
current,
|
|
total,
|
|
secondary: None,
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "tauri"))]
|
|
async fn stored_minecraft_progress_job(
|
|
current: u64,
|
|
total: u64,
|
|
) -> (std::sync::Arc<crate::State>, Uuid, InstallProgressReporter) {
|
|
crate::event::EventState::init().await.unwrap();
|
|
let root = tempfile::tempdir().unwrap().keep();
|
|
let app_state =
|
|
crate::State::init_for_test(root.to_string_lossy().to_string())
|
|
.await
|
|
.unwrap();
|
|
let job_id = Uuid::new_v4();
|
|
let mut job = 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,
|
|
});
|
|
job.set_progress(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
Some(minecraft_progress(current, total)),
|
|
minecraft_details(),
|
|
);
|
|
store::insert(job_id, &job, InstallJobStatus::Running, &app_state)
|
|
.await
|
|
.unwrap();
|
|
let reporter = InstallProgressReporter::new(job_id, job);
|
|
(app_state, job_id, reporter)
|
|
}
|
|
|
|
#[test]
|
|
fn separately_created_reporters_share_job_state() {
|
|
let job_id = Uuid::new_v4();
|
|
let state = 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,
|
|
});
|
|
|
|
let first = InstallProgressReporter::new(job_id, state.clone());
|
|
let second = InstallProgressReporter::new(job_id, state);
|
|
|
|
assert!(Arc::ptr_eq(&first.state, &second.state));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn postponed_java_download_is_shared_by_job_reporters() {
|
|
let job_id = Uuid::new_v4();
|
|
let state = 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,
|
|
});
|
|
let first = InstallProgressReporter::new(job_id, state.clone());
|
|
let second = InstallProgressReporter::new(job_id, state);
|
|
|
|
first.postpone_java_download(21).await;
|
|
|
|
assert!(second.is_java_download_postponed(21).await);
|
|
assert!(!second.is_java_download_postponed(17).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reset_job_starts_resume_with_fresh_typed_state() {
|
|
let job_id = Uuid::new_v4();
|
|
let request = InstallRequest::CreateModpackInstance {
|
|
location: CreatePackLocation::FromFile {
|
|
path: "test.mrpack".into(),
|
|
},
|
|
post_install_edit: None,
|
|
};
|
|
let stale = InstallProgressReporter::new(
|
|
job_id,
|
|
InstallJobState::new(request.clone()),
|
|
);
|
|
let mut resumed = InstallJobState::new(request);
|
|
resumed.missing_content = Some(MissingModpackContentState::default());
|
|
resumed.set_progress(
|
|
InstallPhaseId::DownloadingContent,
|
|
None,
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
resumed.record_event(InstallJobEventKind::WaitingForUser {
|
|
reason: InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec!["mods/missing.jar".to_string()],
|
|
},
|
|
});
|
|
resumed.record_event(InstallJobEventKind::JobQueued {
|
|
kind: InstallJobKind::CreateModpackInstance,
|
|
});
|
|
|
|
InstallProgressReporter::reset_job(job_id);
|
|
let fresh = InstallProgressReporter::new(job_id, resumed);
|
|
|
|
assert!(!Arc::ptr_eq(&stale.state, &fresh.state));
|
|
assert_eq!(
|
|
fresh
|
|
.state
|
|
.lock()
|
|
.await
|
|
.job
|
|
.execution_mode(InstallJobStatus::Running),
|
|
InstallJobExecutionMode::RecoveryValidation
|
|
);
|
|
InstallProgressReporter::reset_job(job_id);
|
|
}
|
|
|
|
#[cfg(not(feature = "tauri"))]
|
|
#[tokio::test]
|
|
async fn first_concrete_progress_after_phase_only_update_is_persisted() {
|
|
crate::event::EventState::init().await.unwrap();
|
|
let root = tempfile::tempdir().unwrap().keep();
|
|
let app_state =
|
|
crate::State::init_for_test(root.to_string_lossy().to_string())
|
|
.await
|
|
.unwrap();
|
|
let job_id = Uuid::new_v4();
|
|
let mut job = 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,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentDownloadStarted {
|
|
files: 1,
|
|
bytes: Some(300),
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileCompleted {
|
|
path: "mods/content.jar".to_string(),
|
|
bytes: 268,
|
|
});
|
|
job.set_progress(
|
|
InstallPhaseId::DownloadingContent,
|
|
Some(InstallProgress {
|
|
current: 1,
|
|
total: 1,
|
|
secondary: Some(InstallProgressSecondary {
|
|
current: 268,
|
|
total: 300,
|
|
}),
|
|
}),
|
|
InstallPhaseDetails::Empty,
|
|
);
|
|
store::insert(job_id, &job, InstallJobStatus::Running, &app_state)
|
|
.await
|
|
.unwrap();
|
|
let reporter = InstallProgressReporter::new(job_id, job);
|
|
let minecraft_details = minecraft_details();
|
|
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
None,
|
|
minecraft_details.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
Some(minecraft_progress(0, 18)),
|
|
minecraft_details,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snapshot = store::get_required(job_id, &app_state)
|
|
.await
|
|
.unwrap()
|
|
.snapshot();
|
|
assert_eq!(snapshot.phase, InstallPhaseId::DownloadingMinecraft);
|
|
let progress = snapshot.progress.unwrap();
|
|
assert_eq!(progress.current, 0);
|
|
assert_eq!(progress.total, 18);
|
|
assert_eq!(snapshot.summary.bytes_downloaded, 0);
|
|
assert_eq!(snapshot.summary.bytes_total, Some(18));
|
|
InstallProgressReporter::reset_job(job_id);
|
|
}
|
|
|
|
#[cfg(not(feature = "tauri"))]
|
|
#[tokio::test]
|
|
async fn same_progress_counter_remains_throttled() {
|
|
let (app_state, job_id, reporter) =
|
|
stored_minecraft_progress_job(0, 18).await;
|
|
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
Some(minecraft_progress(1, 18)),
|
|
minecraft_details(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snapshot = store::get_required(job_id, &app_state)
|
|
.await
|
|
.unwrap()
|
|
.snapshot();
|
|
let progress = snapshot.progress.unwrap();
|
|
assert_eq!(progress.current, 0);
|
|
assert_eq!(progress.total, 18);
|
|
InstallProgressReporter::reset_job(job_id);
|
|
}
|
|
|
|
#[cfg(not(feature = "tauri"))]
|
|
#[tokio::test]
|
|
async fn changed_progress_total_is_persisted_immediately() {
|
|
let (app_state, job_id, reporter) =
|
|
stored_minecraft_progress_job(5, 18).await;
|
|
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
Some(minecraft_progress(0, 20)),
|
|
minecraft_details(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snapshot = store::get_required(job_id, &app_state)
|
|
.await
|
|
.unwrap()
|
|
.snapshot();
|
|
let progress = snapshot.progress.unwrap();
|
|
assert_eq!(progress.current, 0);
|
|
assert_eq!(progress.total, 20);
|
|
InstallProgressReporter::reset_job(job_id);
|
|
}
|
|
|
|
#[test]
|
|
fn download_request_update_matches_frontend_event_contract() {
|
|
let job_id =
|
|
Uuid::parse_str("e7df84c8-b960-4ddb-a75b-bc9012405f1e").unwrap();
|
|
let update = DownloadRequestUpdate::Started {
|
|
job_id,
|
|
id: "mods/example.jar".to_string(),
|
|
name: "example.jar".to_string(),
|
|
url: "https://cdn.modrinth.com/data/example.jar".to_string(),
|
|
source: "official".to_string(),
|
|
bytes_total: Some(4096),
|
|
attempt: 2,
|
|
max_attempts: 4,
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(update).unwrap(),
|
|
serde_json::json!({
|
|
"type": "started",
|
|
"job_id": "e7df84c8-b960-4ddb-a75b-bc9012405f1e",
|
|
"id": "mods/example.jar",
|
|
"name": "example.jar",
|
|
"url": "https://cdn.modrinth.com/data/example.jar",
|
|
"source": "official",
|
|
"bytes_total": 4096,
|
|
"attempt": 2,
|
|
"max_attempts": 4,
|
|
})
|
|
);
|
|
|
|
let progress = DownloadRequestUpdate::Progress {
|
|
job_id,
|
|
id: "mods/example.jar".to_string(),
|
|
bytes: 2048,
|
|
status: DownloadItemStatus::Verifying,
|
|
speed_bytes_per_second: None,
|
|
eta_seconds: None,
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(progress).unwrap(),
|
|
serde_json::json!({
|
|
"type": "progress",
|
|
"job_id": "e7df84c8-b960-4ddb-a75b-bc9012405f1e",
|
|
"id": "mods/example.jar",
|
|
"bytes": 2048,
|
|
"status": "verifying",
|
|
"speed_bytes_per_second": null,
|
|
"eta_seconds": null,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recovered_curseforge_manual_item_clears_pause_count() {
|
|
let path = "manual.jar".to_string();
|
|
let mut job = InstallJobState::new(InstallRequest::CreateInstance {
|
|
name: "CurseForge pack".to_string(),
|
|
game_version: "1.12.2".to_string(),
|
|
loader: ModLoader::Forge,
|
|
loader_version: None,
|
|
adjuncts: Vec::new(),
|
|
icon_path: None,
|
|
link: InstanceLink::CurseForgeModpack {
|
|
project_id: "123".to_string(),
|
|
version_id: "456".to_string(),
|
|
},
|
|
game_dir_override: None,
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileSkipped {
|
|
path: path.clone(),
|
|
reason: "CurseForge requires manual download".to_string(),
|
|
project_id: Some("10".to_string()),
|
|
version_id: Some("20".to_string()),
|
|
manual_url: Some(
|
|
"https://www.curseforge.com/minecraft/mc-mods/example/download/20"
|
|
.to_string(),
|
|
),
|
|
});
|
|
job.pause_reason = Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 1,
|
|
paths: vec![path.clone()],
|
|
});
|
|
job.record_event(InstallJobEventKind::ContentFileRecovered {
|
|
path,
|
|
bytes: 12,
|
|
});
|
|
|
|
refresh_missing_pause_reason(&mut job);
|
|
|
|
assert_eq!(
|
|
job.pause_reason,
|
|
Some(InstallPauseReason::MissingRequiredContent {
|
|
failed_files: 0,
|
|
paths: Vec::new(),
|
|
})
|
|
);
|
|
}
|
|
}
|