lighty-launch 26.9.3

Minecraft launch logic for Lighty Launcher
Documentation
// Copyright (c) 2025 Hamadi
// Licensed under the MIT License

//! Modpack pipeline orchestrator: download, cache, extract, dispatch, record.

#![cfg(any(feature = "modrinth", feature = "curseforge"))]

use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use lighty_core::download::download_file_untracked;
use lighty_core::hash::calculate_sha1_bytes;
use lighty_core::{AppState, QueryError};
use lighty_loaders::types::version_metadata::Mods;
use lighty_loaders::types::VersionInfo;
use lighty_modsloader::modpack::ModpackSource;

use crate::errors::{InstallerError, InstallerResult};

#[cfg(feature = "events")]
use lighty_event::{Event, EventBus, ModloaderEvent};

/// Runs the modpack pipeline for `source` and returns the `Vec<Mods>`
/// to merge into the version pivot before install.
pub(crate) async fn process<V>(
    source: &ModpackSource,
    version: &V,
    #[cfg(feature = "events")] event_bus: Option<&EventBus>,
) -> InstallerResult<Vec<Mods>>
where
    V: VersionInfo,
{
    let source_desc = describe_source(source);

    #[cfg(feature = "events")]
    if let Some(bus) = event_bus {
        bus.emit(Event::Modloader(ModloaderEvent::ModpackResolveStart {
            source: source_desc.clone(),
        }));
    }
    lighty_core::trace_info!("[Modpack] Resolving archive URL for {source_desc}");

    let archive_url = resolve_archive_url(source).await?;

    // Stable cache filename keyed by SHA1 of the resolved URL.
    let url_sha1 = calculate_sha1_bytes(archive_url.as_bytes());
    let cache_dir = AppState::cache_dir().join("modpacks");
    tokio::fs::create_dir_all(&cache_dir).await?;
    let archive_path = cache_dir.join(format!("{url_sha1}.archive"));
    let work_dir = cache_dir.join(format!("work-{url_sha1}"));

    let record_path = install_record_path(version, &url_sha1);

    if let Some(mods) = read_install_record(&record_path).await {
        lighty_core::trace_info!(
            "[Modpack] Already installed for this instance, {} mods from record",
            mods.len()
        );
        return Ok(mods);
    }

    purge_stale_archives(&cache_dir).await;

    if archive_path.exists() {
        touch(&archive_path).await;
    } else {
        lighty_core::trace_info!("[Modpack] Downloading archive to {}", archive_path.display());
        download_file_untracked(&archive_url, &archive_path).await?;
    }

    let archive_bytes = tokio::fs::metadata(&archive_path).await?.len();
    #[cfg(feature = "events")]
    if let Some(bus) = event_bus {
        bus.emit(Event::Modloader(ModloaderEvent::ModpackArchiveDownloaded {
            sha1: url_sha1.clone(),
            bytes: archive_bytes,
        }));
    }

    if work_dir.exists() {
        tokio::fs::remove_dir_all(&work_dir).await?;
    }
    tokio::fs::create_dir_all(&work_dir).await?;
    extract_zip(&archive_path, &work_dir).await?;

    let mods = if work_dir.join("modrinth.index.json").exists() {
        dispatch_mrpack(&work_dir, version).await?
    } else if work_dir.join("manifest.json").exists() {
        dispatch_cf_modpack(&work_dir, version).await?
    } else {
        return Err(InstallerError::Query(QueryError::UnsupportedFormat {
            what: "modpack archive".to_string(),
            expected: "modrinth.index.json or manifest.json".to_string(),
            found: "no recognised manifest".to_string(),
        }));
    };

    write_install_record(&record_path, &mods).await?;

    if work_dir.exists() {
        let _ = tokio::fs::remove_dir_all(&work_dir).await;
    }

    #[cfg(feature = "events")]
    if let Some(bus) = event_bus {
        bus.emit(Event::Modloader(ModloaderEvent::ModpackInstalled {
            name: source_desc.clone(),
            mods_count: mods.len(),
        }));
    }

    lighty_core::trace_info!(
        "[Modpack] Install complete: {} mods queued for download ({} bytes archive)",
        mods.len(),
        archive_bytes
    );

    Ok(mods)
}

const ARCHIVE_RETENTION: Duration = Duration::from_secs(30 * 86_400);

async fn purge_stale_archives(cache_dir: &Path) {
    let Ok(mut entries) = tokio::fs::read_dir(cache_dir).await else {
        return;
    };

    while let Ok(Some(entry)) = entries.next_entry().await {
        let path = entry.path();
        if path.extension().is_none_or(|ext| ext != "archive") {
            continue;
        }

        let stale = entry
            .metadata()
            .await
            .ok()
            .and_then(|meta| meta.modified().ok())
            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
            .is_some_and(|age| age > ARCHIVE_RETENTION);

        if stale {
            lighty_core::trace_info!("[Modpack] Purging stale archive {}", path.display());
            let _ = tokio::fs::remove_file(&path).await;
        }
    }
}

async fn touch(path: &Path) {
    let path = path.to_path_buf();
    let _ = tokio::task::spawn_blocking(move || {
        let times = std::fs::FileTimes::new()
            .set_accessed(SystemTime::now())
            .set_modified(SystemTime::now());
        std::fs::File::options()
            .write(true)
            .open(&path)
            .and_then(|file| file.set_times(times))
    })
    .await;
}

fn install_record_path<V: VersionInfo>(version: &V, url_sha1: &str) -> PathBuf {
    version
        .game_dirs()
        .join(".modpack")
        .join(format!("{url_sha1}.json"))
}

async fn read_install_record(path: &Path) -> Option<Vec<Mods>> {
    let raw = tokio::fs::read_to_string(path).await.ok()?;
    serde_json::from_str(&raw).ok()
}

async fn write_install_record(path: &Path, mods: &[Mods]) -> InstallerResult<()> {
    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }

    let payload = serde_json::to_string(mods)
        .map_err(|error| InstallerError::Query(QueryError::JsonParsing(error)))?;
    tokio::fs::write(path, payload).await?;
    Ok(())
}

async fn resolve_archive_url(source: &ModpackSource) -> InstallerResult<String> {
    match source {
        #[cfg(feature = "modrinth")]
        ModpackSource::ModrinthUrl(_) | ModpackSource::ModrinthPinned { .. } => {
            Ok(lighty_modsloader::modrinth::modpack::resolve_mrpack_url(source).await?)
        }
        #[cfg(feature = "curseforge")]
        ModpackSource::CurseForgePinned { .. } => {
            Ok(lighty_modsloader::curseforge::modpack::resolve_cf_modpack_url(source).await?)
        }
        #[allow(unreachable_patterns)]
        _ => Err(InstallerError::Query(QueryError::Conversion {
            message: "Modpack source does not match any enabled provider feature".into(),
        })),
    }
}

fn describe_source(source: &ModpackSource) -> String {
    match source {
        #[cfg(feature = "modrinth")]
        ModpackSource::ModrinthUrl(url) => format!("ModrinthUrl({url})"),
        #[cfg(feature = "modrinth")]
        ModpackSource::ModrinthPinned { project, version } => match version {
            Some(v) => format!("ModrinthPinned({project}@{v})"),
            None => format!("ModrinthPinned({project}@latest)"),
        },
        #[cfg(feature = "curseforge")]
        ModpackSource::CurseForgePinned { project_id, file_id } => {
            format!("CurseForgePinned({project_id}/{file_id})")
        }
    }
}

/// Synchronous ZIP extraction wrapped in `spawn_blocking`.
async fn extract_zip(archive_path: &Path, dest: &Path) -> InstallerResult<()> {
    let archive_path = archive_path.to_path_buf();
    let dest = dest.to_path_buf();
    let archive_label = archive_path.display().to_string();
    let archive_label_for_open = archive_label.clone();
    let archive_label_for_extract = archive_label.clone();
    tokio::task::spawn_blocking(move || -> InstallerResult<()> {
        let file = std::fs::File::open(&archive_path)?;
        let mut archive = zip::ZipArchive::new(file).map_err(|e| {
            InstallerError::Query(QueryError::ArchiveCorrupted {
                context: format!("modpack archive {archive_label_for_open}"),
                reason: e.to_string(),
            })
        })?;
        archive.extract(&dest).map_err(|e| {
            InstallerError::Query(QueryError::ArchiveCorrupted {
                context: format!("modpack archive {archive_label_for_extract}"),
                reason: e.to_string(),
            })
        })?;
        Ok(())
    })
    .await
    .map_err(|e| {
        InstallerError::Query(QueryError::ArchiveCorrupted {
            context: format!("modpack archive {archive_label}"),
            reason: format!("extract task panicked: {e}"),
        })
    })??;
    Ok(())
}

async fn dispatch_mrpack<V: VersionInfo>(
    work_dir: &Path,
    version: &V,
) -> InstallerResult<Vec<Mods>> {
    #[cfg(feature = "modrinth")]
    {
        super::modrinth::process_mrpack(work_dir, version).await
    }
    #[cfg(not(feature = "modrinth"))]
    {
        let _ = (work_dir, version);
        Err(InstallerError::Query(QueryError::Conversion {
            message: "Found a Modrinth .mrpack but the `modrinth` feature is disabled".into(),
        }))
    }
}

async fn dispatch_cf_modpack<V: VersionInfo>(
    work_dir: &Path,
    version: &V,
) -> InstallerResult<Vec<Mods>> {
    #[cfg(feature = "curseforge")]
    {
        super::curseforge::process_cf_modpack(work_dir, version).await
    }
    #[cfg(not(feature = "curseforge"))]
    {
        let _ = (work_dir, version);
        Err(InstallerError::Query(QueryError::Conversion {
            message: "Found a CurseForge modpack but the `curseforge` feature is disabled".into(),
        }))
    }
}