Files
Starlight_Lancher/packages/app-lib/src/install/import_plan.rs
T

1033 lines
32 KiB
Rust

use std::{
path::{Path, PathBuf},
sync::LazyLock,
};
use daedalus::minecraft::{
AssetsIndex, DownloadType, Library, LoggingConfiguration, LoggingSide,
VersionInfo as GameVersionInfo,
};
use dashmap::DashMap;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use crate::instance::QuickPlayType;
use crate::launcher::download::{
ArtifactAvailability, LocalRuntimeSource, classify_local_artifact,
is_native_library, legacy_library_sha1, library_native_classifier,
local_asset_index_path, local_asset_object_path, local_client_path,
local_library_path, local_log_config_path, local_native_library_path,
native_library_artifact_path, needs_java_artifact,
};
use crate::launcher::parse_rules;
use crate::state::{ModLoader, State};
#[path = "../api/pack/import/instance_json.rs"]
mod instance_json;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportPlanRequest {
pub request_id: String,
pub launcher_type: crate::api::pack::import::ImportLauncherType,
pub base_path: PathBuf,
pub instance_folder: String,
#[serde(default)]
pub instance_path: Option<String>,
#[serde(default)]
pub game_version: Option<String>,
#[serde(default)]
pub loader: Option<crate::state::ModLoader>,
#[serde(default)]
pub loader_version: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportPlanCounts {
pub files: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ImportPlanStage {
Resolving,
Scanning,
Done,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportPlanSnapshot {
pub request_id: String,
pub stage: ImportPlanStage,
pub game_version: Option<String>,
pub loader: Option<String>,
pub loader_version: Option<String>,
pub import_path: String,
pub minecraft_root: String,
pub mod_count: u64,
pub cache: ImportPlanCounts,
pub local: ImportPlanCounts,
pub network: ImportPlanCounts,
pub migrate: ImportPlanCounts,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
struct AxolotlConfigMetadata {
schema_version: Option<u32>,
#[serde(default)]
content_set: AxolotlContentSetMetadata,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
struct AxolotlContentSetMetadata {
#[serde(default)]
game_version: Option<String>,
#[serde(default)]
loader: Option<String>,
#[serde(default)]
loader_version: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct DetectedInstanceInfo {
vanilla_name: Option<String>,
loader: Option<String>,
loader_version: Option<String>,
}
static IMPORT_PLAN_CANCELLATIONS: LazyLock<DashMap<String, CancellationToken>> =
LazyLock::new(DashMap::new);
pub fn start_import_plan(request: ImportPlanRequest) -> crate::Result<String> {
let request_id = request.request_id.clone();
if request_id.trim().is_empty() {
return Err(crate::ErrorKind::InputError(
"Import plan request id must not be empty".to_string(),
)
.into());
}
let cancellation = CancellationToken::new();
IMPORT_PLAN_CANCELLATIONS.insert(request_id.clone(), cancellation.clone());
tokio::spawn(async move {
let result = run_import_plan(&request, &cancellation).await;
IMPORT_PLAN_CANCELLATIONS.remove(&request.request_id);
if let Err(error) = result {
tracing::error!(
request_id = %request.request_id,
"Import plan failed: {error}"
);
}
});
Ok(request_id)
}
pub async fn cancel_import_plan(request_id: &str) -> crate::Result<()> {
if let Some((_, cancellation)) =
IMPORT_PLAN_CANCELLATIONS.remove(request_id)
{
cancellation.cancel();
}
Ok(())
}
async fn run_import_plan(
request: &ImportPlanRequest,
cancellation: &CancellationToken,
) -> crate::Result<()> {
if cancellation.is_cancelled() {
return Ok(());
}
let source = resolve_source(request)?;
let import_path = source.to_string_lossy().to_string();
let dotminecraft = if source.join(".minecraft").is_dir() {
source.join(".minecraft")
} else {
source.clone()
};
let local_source = LocalRuntimeSource::discover(&dotminecraft);
let minecraft_root = local_source
.as_ref()
.map(|source| source.root.to_string_lossy().to_string())
.unwrap_or_default();
let detected = detect_import_plan_info(&source, &dotminecraft);
let game_version = request
.game_version
.clone()
.filter(|version| !version.trim().is_empty())
.or_else(|| detected.vanilla_name.clone());
let mut loader = request
.loader
.map(|loader| loader.as_str().to_string())
.or_else(|| detected.loader.clone());
let mut loader_version = request
.loader_version
.clone()
.or_else(|| detected.loader_version.clone());
if let Some(loader_name) = loader.as_deref() {
let supported = matches!(
loader_name,
"forge"
| "neoforge"
| "fabric"
| "quilt"
| "optifine"
| "cleanroom"
| "lite_loader"
| "legacy_fabric"
);
if !supported {
loader = None;
loader_version = None;
}
}
emit_import_plan(&snapshot_for(
request,
ImportPlanStage::Resolving,
&import_path,
&minecraft_root,
None,
None,
None,
0,
ImportPlanCountsBySource::default(),
ImportPlanCounts::default(),
None,
))
.await?;
if cancellation.is_cancelled() {
return Ok(());
}
emit_import_plan(&snapshot_for(
request,
ImportPlanStage::Scanning,
&import_path,
&minecraft_root,
game_version.clone(),
loader.clone(),
loader_version.clone(),
0,
ImportPlanCountsBySource::default(),
ImportPlanCounts::default(),
None,
))
.await?;
if cancellation.is_cancelled() {
return Ok(());
}
let result = match scan_import_plan(
request,
&source,
&dotminecraft,
local_source.as_ref(),
&detected,
cancellation,
)
.await
{
Ok(result) => result,
Err(error) => {
if cancellation.is_cancelled() {
return Ok(());
}
let snapshot = snapshot_for(
request,
ImportPlanStage::Error,
&import_path,
&minecraft_root,
None,
None,
None,
0,
ImportPlanCountsBySource::default(),
ImportPlanCounts::default(),
Some(error.to_string()),
);
emit_import_plan(&snapshot).await?;
return Err(error);
}
};
if cancellation.is_cancelled() {
return Ok(());
}
for counts in &result.category_progress {
emit_import_plan(&snapshot_for(
request,
ImportPlanStage::Scanning,
&import_path,
&minecraft_root,
game_version.clone(),
loader.clone(),
loader_version.clone(),
result.mod_count,
*counts,
result.migrate,
None,
))
.await?;
if cancellation.is_cancelled() {
return Ok(());
}
}
emit_import_plan(&snapshot_for(
request,
ImportPlanStage::Done,
&import_path,
&minecraft_root,
game_version,
loader,
loader_version,
result.mod_count,
result.counts,
result.migrate,
None,
))
.await?;
Ok(())
}
#[derive(Debug, Clone, Copy, Default)]
struct ImportPlanCountsBySource {
cache: ImportPlanCounts,
local: ImportPlanCounts,
network: ImportPlanCounts,
}
impl std::ops::AddAssign for ImportPlanCountsBySource {
fn add_assign(&mut self, rhs: Self) {
self.cache.files += rhs.cache.files;
self.cache.bytes += rhs.cache.bytes;
self.local.files += rhs.local.files;
self.local.bytes += rhs.local.bytes;
self.network.files += rhs.network.files;
self.network.bytes += rhs.network.bytes;
}
}
#[derive(Debug, Clone, Default)]
struct ImportPlanScanResult {
mod_count: u64,
migrate: ImportPlanCounts,
counts: ImportPlanCountsBySource,
category_progress: Vec<ImportPlanCountsBySource>,
}
#[derive(Debug, Clone)]
struct RequiredArtifact {
relative_path: PathBuf,
destination: PathBuf,
expected_sha1: Option<String>,
expected_size: Option<u64>,
}
fn library_classifier_coordinate_is_native(library: &Library) -> bool {
library
.name
.split(':')
.nth(3)
.and_then(|classifier| classifier.split('@').next())
.is_some_and(|classifier| classifier.starts_with("natives-"))
}
fn snapshot_for(
request: &ImportPlanRequest,
stage: ImportPlanStage,
import_path: &str,
minecraft_root: &str,
game_version: Option<String>,
loader: Option<String>,
loader_version: Option<String>,
mod_count: u64,
counts: ImportPlanCountsBySource,
migrate: ImportPlanCounts,
error: Option<String>,
) -> ImportPlanSnapshot {
ImportPlanSnapshot {
request_id: request.request_id.clone(),
stage,
game_version,
loader,
loader_version,
import_path: import_path.to_string(),
minecraft_root: minecraft_root.to_string(),
mod_count,
cache: counts.cache,
local: counts.local,
network: counts.network,
migrate,
error,
}
}
fn resolve_source(request: &ImportPlanRequest) -> crate::Result<PathBuf> {
let Some(instance_path) = &request.instance_path else {
return Err(crate::ErrorKind::InputError(
"Import source path is required".to_string(),
)
.into());
};
let source = PathBuf::from(instance_path);
if !source.is_dir() {
return Err(crate::ErrorKind::InputError(format!(
"Import source path does not exist: {}",
source.display()
))
.into());
}
Ok(source)
}
fn detect_import_plan_info(
source: &Path,
dotminecraft: &Path,
) -> DetectedInstanceInfo {
let json_info = instance_json::detect(dotminecraft);
if let Some(info) = &json_info {
tracing::debug!(
adjunct_count = info.adjuncts.len(),
"Detected imported loader adjuncts"
);
}
let config_info = read_axolotl_config(source);
DetectedInstanceInfo {
vanilla_name: config_info
.as_ref()
.and_then(|config| {
config
.content_set
.game_version
.clone()
.filter(|version| !version.trim().is_empty())
})
.or_else(|| {
json_info.as_ref().map(|info| info.vanilla_name.clone())
}),
loader: config_info
.as_ref()
.and_then(|config| {
config
.content_set
.loader
.clone()
.filter(|loader| !loader.trim().is_empty())
})
.or_else(|| {
json_info.as_ref().and_then(|info| info.loader.clone())
}),
loader_version: config_info
.as_ref()
.and_then(|config| {
config
.content_set
.loader_version
.clone()
.filter(|version| !version.trim().is_empty())
})
.or_else(|| {
json_info
.as_ref()
.and_then(|info| info.loader_version.clone())
}),
}
}
fn read_axolotl_config(source: &Path) -> Option<AxolotlConfigMetadata> {
let path = source.join("axolotl_config.json");
if !path.is_file() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let config: AxolotlConfigMetadata = serde_json::from_str(&content).ok()?;
(config.schema_version == Some(1)).then_some(config)
}
async fn scan_import_plan(
request: &ImportPlanRequest,
source: &Path,
dotminecraft: &Path,
local_source: Option<&LocalRuntimeSource>,
detected: &DetectedInstanceInfo,
cancellation: &CancellationToken,
) -> crate::Result<ImportPlanScanResult> {
let import_path = source.to_string_lossy().to_string();
let minecraft_root = local_source
.map(|source| source.root.to_string_lossy().to_string())
.unwrap_or_default();
let state = State::get().await?;
let game_version = request
.game_version
.clone()
.filter(|version| !version.trim().is_empty())
.or_else(|| detected.vanilla_name.clone())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not detect a Minecraft version; provide one to continue"
.to_string(),
)
})?;
let detected_loader =
detected.loader.as_deref().map(str::to_ascii_lowercase);
if detected_loader.as_deref().is_some_and(|loader| {
loader == "labymod" || loader.starts_with("labymod-")
}) {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
let loader = request
.loader
.or_else(|| {
detected_loader
.as_deref()
.and_then(|loader_name| match loader_name {
"forge" => Some(ModLoader::Forge),
"neoforge" | "neo_forge" => Some(ModLoader::NeoForge),
"fabric" => Some(ModLoader::Fabric),
"quilt" => Some(ModLoader::Quilt),
"optifine" => Some(ModLoader::OptiFine),
"cleanroom" => Some(ModLoader::Cleanroom),
"lite_loader" | "liteloader" => Some(ModLoader::LiteLoader),
"legacy_fabric" | "legacyfabric" => {
Some(ModLoader::LegacyFabric)
}
_ => None,
})
})
.unwrap_or(ModLoader::Vanilla);
let loader_version = request
.loader_version
.clone()
.or_else(|| detected.loader_version.clone());
if let Some(detected_loader) = detected_loader
&& loader == ModLoader::Vanilla
&& detected_loader != "vanilla"
{
return Err(crate::ErrorKind::InputError(format!(
"Unsupported loader {detected_loader}: the instance was not planned as Vanilla"
))
.into());
}
let requested_loader_version = loader_version
.as_deref()
.filter(|version| !version.is_empty() && *version != "latest");
let loader_name = loader.as_str().to_string();
let game_version_option = Some(game_version.clone());
let loader_option = Some(loader_name);
let (mod_count, migrate) = scan_counts_emitting(
request,
dotminecraft,
&import_path,
&minecraft_root,
&game_version_option,
&loader_option,
&loader_version,
cancellation,
)
.await?;
if cancellation.is_cancelled() {
return Ok(ImportPlanScanResult::default());
}
let categories = build_runtime_artifact_categories(
&state,
local_source,
&game_version,
loader,
requested_loader_version,
)
.await?;
if cancellation.is_cancelled() {
return Ok(ImportPlanScanResult::default());
}
let mut counts = ImportPlanCountsBySource::default();
let mut category_progress = Vec::with_capacity(categories.len());
for artifacts in categories {
if cancellation.is_cancelled() {
break;
}
counts +=
classify_artifacts(local_source, &artifacts, cancellation).await?;
category_progress.push(counts);
}
Ok(ImportPlanScanResult {
mod_count,
migrate,
counts,
category_progress,
})
}
async fn build_runtime_artifact_categories(
state: &State,
local_source: Option<&LocalRuntimeSource>,
game_version: &str,
loader: ModLoader,
requested_loader_version: Option<&str>,
) -> crate::Result<Vec<Vec<RequiredArtifact>>> {
let (manifest, version_index) =
crate::launcher::resolve_minecraft_manifest(game_version, state)
.await?;
let version = &manifest.versions[version_index];
let minecraft_updated = version_index
<= manifest
.versions
.iter()
.position(|entry| entry.id == "22w16a")
.unwrap_or(0);
let loader_version = if loader == ModLoader::Vanilla {
None
} else {
crate::launcher::get_loader_version_from_profile(
game_version,
loader,
requested_loader_version,
)
.await?
};
let version_info = crate::launcher::download::download_version_info(
state,
version,
loader,
loader_version.as_ref(),
None,
None,
None,
)
.await?;
let java_arch = std::env::consts::ARCH;
let mut categories = Vec::with_capacity(6);
categories.push(client_artifacts(state, game_version, &version_info)?);
categories.push(asset_index_artifacts(state, &version_info)?);
categories.push(log_config_artifacts(state, &version_info)?);
let (libraries, natives) =
library_artifacts(state, &version_info, java_arch, minecraft_updated)?;
categories.push(libraries);
categories.push(natives);
categories.push(
asset_object_artifacts(state, local_source, &version_info).await?,
);
Ok(categories)
}
fn client_artifacts(
state: &State,
game_version: &str,
version_info: &GameVersionInfo,
) -> crate::Result<Vec<RequiredArtifact>> {
let client = version_info
.downloads
.get(&DownloadType::Client)
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"No client downloads exist for version {}",
version_info.id
))
})?;
let version_id = &version_info.id;
Ok(vec![RequiredArtifact {
relative_path: local_client_path(game_version),
destination: state
.directories
.version_dir(version_id)
.join(format!("{version_id}.jar")),
expected_sha1: Some(client.sha1.clone()),
expected_size: Some(client.size as u64),
}])
}
fn asset_index_artifacts(
state: &State,
version_info: &GameVersionInfo,
) -> crate::Result<Vec<RequiredArtifact>> {
let index = &version_info.asset_index;
Ok(vec![RequiredArtifact {
relative_path: local_asset_index_path(&index.id),
destination: state
.directories
.assets_index_dir()
.join(format!("{}.json", index.id)),
expected_sha1: Some(index.sha1.clone()),
expected_size: Some(index.size as u64),
}])
}
fn log_config_artifacts(
state: &State,
version_info: &GameVersionInfo,
) -> crate::Result<Vec<RequiredArtifact>> {
let Some(logging) = version_info
.logging
.as_ref()
.and_then(|logging| logging.get(&LoggingSide::Client))
else {
return Ok(Vec::new());
};
let LoggingConfiguration::Log4j2Xml { file, .. } = logging;
Ok(vec![RequiredArtifact {
relative_path: local_log_config_path(&file.id),
destination: state.directories.log_configs_dir().join(&file.id),
expected_sha1: Some(file.sha1.clone()),
expected_size: Some(file.size as u64),
}])
}
fn library_artifacts(
state: &State,
version_info: &GameVersionInfo,
java_arch: &str,
minecraft_updated: bool,
) -> crate::Result<(Vec<RequiredArtifact>, Vec<RequiredArtifact>)> {
let mut libraries = Vec::new();
let mut natives = Vec::new();
for library in &version_info.libraries {
if let Some(rules) = &library.rules
&& !parse_rules(
rules,
java_arch,
&QuickPlayType::None,
minecraft_updated,
)
{
continue;
}
if !library.downloadable {
continue;
}
// Native library artifact for this platform, if any.
if is_native_library(library) {
if let Some(classifier) =
library_native_classifier(library, java_arch)
{
let native = library
.downloads
.as_ref()
.and_then(|downloads| downloads.classifiers.as_ref())
.and_then(|classifiers| classifiers.get(&classifier));
if let Some(native) = native {
natives.push(RequiredArtifact {
relative_path: local_native_library_path(
library,
native,
&classifier,
)?,
destination: state
.directories
.caches_dir()
.join("minecraft-natives")
.join(format!("{}.jar", native.sha1)),
expected_sha1: Some(native.sha1.clone()),
expected_size: Some(native.size as u64),
});
} else if library_classifier_coordinate_is_native(library) {
// Forge and newer manifests may represent a native as a
// four-part coordinate (group:artifact:version:natives-*),
// with its metadata in downloads.artifact rather than the
// legacy downloads.classifiers map. Keep that artifact in
// libraries/ so native preparation can consume it directly.
if let Some(artifact) = library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.filter(|artifact| !artifact.url.is_empty())
{
let artifact_path =
native_library_artifact_path(library, &classifier)?;
natives.push(RequiredArtifact {
relative_path: Path::new("libraries")
.join(&artifact_path),
destination: state
.directories
.libraries_dir()
.join(&artifact_path),
expected_sha1: Some(artifact.sha1.clone()),
expected_size: Some(artifact.size as u64),
});
}
}
}
}
// Java artifact (regular JAR). Mixed libraries carry both.
if needs_java_artifact(library) {
let artifact_path =
daedalus::get_path_from_artifact(&library.name)?;
let (expected_sha1, expected_size) = if let Some(artifact) = library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.filter(|artifact| !artifact.url.is_empty())
{
(Some(artifact.sha1.clone()), Some(artifact.size as u64))
} else {
(legacy_library_sha1(library).map(str::to_string), None)
};
libraries.push(RequiredArtifact {
relative_path: local_library_path(&library.name)?,
destination: state
.directories
.libraries_dir()
.join(&artifact_path),
expected_sha1,
expected_size,
});
}
}
Ok((libraries, natives))
}
async fn asset_object_artifacts(
state: &State,
local_source: Option<&LocalRuntimeSource>,
version_info: &GameVersionInfo,
) -> crate::Result<Vec<RequiredArtifact>> {
let index = load_assets_index(state, local_source, version_info).await?;
let mut artifacts = Vec::with_capacity(index.objects.len());
for asset in index.objects.values() {
artifacts.push(RequiredArtifact {
relative_path: local_asset_object_path(&asset.hash),
destination: state.directories.object_dir(&asset.hash),
expected_sha1: Some(asset.hash.clone()),
expected_size: Some(asset.size as u64),
});
}
Ok(artifacts)
}
async fn load_assets_index(
state: &State,
local_source: Option<&LocalRuntimeSource>,
version_info: &GameVersionInfo,
) -> crate::Result<AssetsIndex> {
let index_path = state
.directories
.assets_index_dir()
.join(format!("{}.json", version_info.asset_index.id));
if index_path.is_file() {
let bytes = crate::util::io::read(&index_path).await?;
return Ok(serde_json::from_slice(&bytes)?);
}
if let Some(local) = local_source {
let relative = local_asset_index_path(&version_info.asset_index.id);
let candidate = local.root.join(&relative);
if candidate.is_file()
&& matches!(
classify_local_artifact(
Some(local),
&index_path,
&relative,
Some(&version_info.asset_index.sha1),
Some(version_info.asset_index.size as u64),
)
.await?,
ArtifactAvailability::LocalReusable
)
{
let bytes = crate::util::io::read(&candidate).await?;
return Ok(serde_json::from_slice(&bytes)?);
}
}
crate::util::fetch::fetch_json(
Method::GET,
&version_info.asset_index.url,
Some(&version_info.asset_index.sha1),
None,
None,
&state.api_semaphore,
&state.pool,
)
.await
}
async fn classify_artifacts(
local: Option<&LocalRuntimeSource>,
artifacts: &[RequiredArtifact],
cancellation: &CancellationToken,
) -> crate::Result<ImportPlanCountsBySource> {
let mut counts = ImportPlanCountsBySource::default();
for artifact in artifacts {
if cancellation.is_cancelled() {
break;
}
let availability = classify_local_artifact(
local,
&artifact.destination,
&artifact.relative_path,
artifact.expected_sha1.as_deref(),
artifact.expected_size,
)
.await?;
let bytes = match artifact.expected_size {
Some(size) => size,
None => match availability {
ArtifactAvailability::Cached => {
crate::util::io::metadata(&artifact.destination)
.await
.map(|metadata| metadata.len())
.unwrap_or(0)
}
ArtifactAvailability::LocalReusable => match local {
Some(source) => crate::util::io::metadata(
&source.root.join(&artifact.relative_path),
)
.await
.map(|metadata| metadata.len())
.unwrap_or(0),
None => 0,
},
ArtifactAvailability::NetworkRequired => 0,
},
};
match availability {
ArtifactAvailability::Cached => {
counts.cache.files += 1;
counts.cache.bytes += bytes;
}
ArtifactAvailability::LocalReusable => {
counts.local.files += 1;
counts.local.bytes += bytes;
}
ArtifactAvailability::NetworkRequired => {
counts.network.files += 1;
counts.network.bytes += bytes;
}
}
}
Ok(counts)
}
async fn scan_counts_emitting(
request: &ImportPlanRequest,
dotminecraft: &Path,
import_path: &str,
minecraft_root: &str,
game_version: &Option<String>,
loader: &Option<String>,
loader_version: &Option<String>,
cancellation: &CancellationToken,
) -> crate::Result<(u64, ImportPlanCounts)> {
// TODO(B2): when overrides change, recompute cache/local/network
// incrementally without rescanning the full .minecraft tree.
let excluded_roots = ["assets", "libraries", "versions"];
let dirname = dotminecraft
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
let skip_json = format!("{dirname}.json");
let skip_jar = format!("{dirname}.jar");
let mods_dir = dotminecraft.join("mods");
let mut mod_count = 0u64;
let mut migrate = ImportPlanCounts::default();
let mut stack = vec![dotminecraft.to_path_buf()];
let mut processed = 0u64;
while let Some(dir) = stack.pop() {
if cancellation.is_cancelled() {
break;
}
let mut entries = tokio::fs::read_dir(&dir).await.map_err(|error| {
crate::util::io::IOError::with_path(error, &dir)
})?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|error| crate::util::io::IOError::with_path(error, &dir))?
{
if cancellation.is_cancelled() {
break;
}
let path = entry.path();
if dir == dotminecraft
&& excluded_roots
.iter()
.any(|name| entry.file_name().to_string_lossy() == *name)
{
continue;
}
let metadata =
tokio::fs::symlink_metadata(&path).await.map_err(|error| {
crate::util::io::IOError::with_path(error, &path)
})?;
if metadata.is_dir() && !metadata.file_type().is_symlink() {
stack.push(path);
continue;
}
if dir == dotminecraft {
let name = entry.file_name().to_string_lossy().to_string();
if name == skip_json || name == skip_jar {
continue;
}
}
migrate.files += 1;
migrate.bytes += metadata.len();
if path.strip_prefix(&mods_dir).is_ok()
&& path.extension().map(|ext| ext == "jar").unwrap_or(false)
{
mod_count += 1;
}
processed += 1;
if processed <= 64 || processed.is_multiple_of(16) {
emit_import_plan(&snapshot_for(
request,
ImportPlanStage::Scanning,
import_path,
minecraft_root,
game_version.clone(),
loader.clone(),
loader_version.clone(),
mod_count,
ImportPlanCountsBySource::default(),
migrate,
None,
))
.await?;
}
}
}
Ok((mod_count, migrate))
}
#[allow(unused_variables)]
async fn emit_import_plan(snapshot: &ImportPlanSnapshot) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
use tauri::Emitter;
let event_state = crate::EventState::get()?;
event_state
.app
.emit("import_plan", snapshot)
.map_err(crate::event::EventError::from)?;
}
Ok(())
}