416 lines
10 KiB
Rust
416 lines
10 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use oneclient_db::dao::{
|
|
artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as cluster_bundle_dao,
|
|
};
|
|
use oneclient_db::models::NewCluster;
|
|
use oneclient_db::DbPool;
|
|
use strum::IntoEnumIterator;
|
|
|
|
use crate::clusters::ClusterStage;
|
|
use oneclient_content::packages::PackageStore;
|
|
use polyio::sha1_file;
|
|
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
|
|
use oneclient_common::paths;
|
|
use oneclient_cluster::profiles::create_profile_from_global;
|
|
use crate::state::LauncherState;
|
|
use oneclient_common::version::parse_mc_version;
|
|
use crate::LauncherResult;
|
|
|
|
const FILE_CONTENT_TYPES: [ContentType; 4] = [
|
|
ContentType::Mod,
|
|
ContentType::ResourcePack,
|
|
ContentType::Shader,
|
|
ContentType::DataPack,
|
|
];
|
|
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct RecoveryReport {
|
|
pub adopted_clusters: usize,
|
|
pub indexed_artifacts: usize,
|
|
pub relinked_files: usize,
|
|
}
|
|
|
|
impl RecoveryReport {
|
|
pub fn did_recover(&self) -> bool {
|
|
self.adopted_clusters > 0
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(skip(state))]
|
|
pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult<RecoveryReport> {
|
|
let mut report = RecoveryReport::default();
|
|
|
|
// Held across the whole scan+adopt so a concurrent cluster create (folder
|
|
// written before its DB row commits) isn't misclassified as an orphan
|
|
let _guard = state.clusters.provisioning_guard().await;
|
|
|
|
let clusters_dir = paths::clusters_dir()?;
|
|
let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?;
|
|
if orphans.is_empty() {
|
|
return Ok(report);
|
|
}
|
|
|
|
tracing::info!(
|
|
orphan_folders = orphans.len(),
|
|
"user_data.db appears reset; reconstructing from disk"
|
|
);
|
|
|
|
if let Err(err) = state.java.rescan().await {
|
|
tracing::warn!("java rescan during recovery failed: {err:#}");
|
|
}
|
|
|
|
report.indexed_artifacts = rebuild_artifact_cache(&state.services.db).await?;
|
|
|
|
for folder in orphans {
|
|
match adopt_cluster(state, &folder, &mut report).await {
|
|
Ok(()) => {}
|
|
Err(err) => {
|
|
tracing::warn!(folder = %folder, "failed to adopt cluster folder: {err:#}");
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
adopted = report.adopted_clusters,
|
|
artifacts = report.indexed_artifacts,
|
|
files = report.relinked_files,
|
|
"database reconstruction complete"
|
|
);
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip(pool))]
|
|
async fn orphan_cluster_folders(
|
|
pool: &DbPool,
|
|
clusters_dir: &Path,
|
|
) -> LauncherResult<Vec<String>> {
|
|
let mut orphans = Vec::new();
|
|
for name in list_dir_names(clusters_dir).await? {
|
|
if cluster_dao::get_by_folder_name(pool, &name).await?.is_none() {
|
|
orphans.push(name);
|
|
}
|
|
}
|
|
Ok(orphans)
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip(pool))]
|
|
async fn rebuild_artifact_cache(pool: &DbPool) -> LauncherResult<usize> {
|
|
let cache_root = paths::packages_cache_dir()?;
|
|
if !cache_root.exists() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let launcher_dir = paths::launcher_dir()?;
|
|
let mut indexed = 0;
|
|
|
|
for content_name in list_dir_names(&cache_root).await? {
|
|
let Some(content_type) = ContentType::from_folder_name(&content_name) else {
|
|
continue;
|
|
};
|
|
let content_dir = cache_root.join(&content_name);
|
|
|
|
for provider_name in list_dir_names(&content_dir).await? {
|
|
let Some(provider) = provider_from_dir_name(&provider_name) else {
|
|
continue;
|
|
};
|
|
let provider_dir = content_dir.join(&provider_name);
|
|
|
|
for project_id in list_dir_names(&provider_dir).await? {
|
|
let project_dir = provider_dir.join(&project_id);
|
|
|
|
for version_id in list_dir_names(&project_dir).await? {
|
|
let version_dir = project_dir.join(&version_id);
|
|
|
|
for file in list_files(&version_dir).await? {
|
|
if let Err(err) = index_cache_file(
|
|
pool,
|
|
launcher_dir,
|
|
content_type,
|
|
provider,
|
|
&project_id,
|
|
&version_id,
|
|
&file,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(path = %file.display(), "failed to index cached file: {err:#}");
|
|
continue;
|
|
}
|
|
indexed += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(indexed)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[tracing::instrument(level = "debug", skip(pool, launcher_dir, file))]
|
|
async fn index_cache_file(
|
|
pool: &DbPool,
|
|
launcher_dir: &Path,
|
|
content_type: ContentType,
|
|
provider: ProviderId,
|
|
project_id: &str,
|
|
version_id: &str,
|
|
file: &Path,
|
|
) -> LauncherResult<()> {
|
|
let hash = sha1_file(file).await?;
|
|
let stored_path = relative_launcher_path(launcher_dir, file);
|
|
let file_name = file
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let size = polyio::stat(file).await.ok().map(|m| m.len() as i64);
|
|
|
|
artifact_dao::insert_artifact(
|
|
pool,
|
|
&hash,
|
|
content_type as i64,
|
|
&stored_path,
|
|
&file_name,
|
|
size,
|
|
)
|
|
.await?;
|
|
|
|
if matches!(provider, ProviderId::Modrinth | ProviderId::CurseForge) {
|
|
artifact_dao::upsert_provider_release(
|
|
pool,
|
|
provider as i64,
|
|
project_id,
|
|
version_id,
|
|
&hash,
|
|
&file_name,
|
|
version_id,
|
|
None,
|
|
"[]",
|
|
"[]",
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip(state, report))]
|
|
async fn adopt_cluster(
|
|
state: &LauncherState,
|
|
folder_name: &str,
|
|
report: &mut RecoveryReport,
|
|
) -> LauncherResult<()> {
|
|
let (name, mc_version, loader) = parse_folder_identity(folder_name);
|
|
|
|
let global = state.settings.read().global_game_settings.clone();
|
|
let profile = create_profile_from_global(&state.services.db, &global, &name, None, None).await?;
|
|
|
|
let row = cluster_dao::insert(
|
|
&state.services.db,
|
|
&NewCluster {
|
|
name: &name,
|
|
folder_name,
|
|
mc_version: &mc_version,
|
|
mc_loader: loader as i64,
|
|
mc_loader_version: None,
|
|
setting_profile_name: Some(&profile.name),
|
|
stage: ClusterStage::NotReady as i64,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
report.adopted_clusters += 1;
|
|
tracing::info!(
|
|
cluster_id = row.id,
|
|
folder = folder_name,
|
|
mc_version = %mc_version,
|
|
loader = %loader,
|
|
"adopted cluster folder from disk"
|
|
);
|
|
|
|
relink_cluster_files(state, row.id, folder_name, report).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Files are taken into the artifact cache not indexed in place the cache is
|
|
/// the only place content is materialized from so in-place rows break on tidy-up
|
|
#[tracing::instrument(level = "debug", skip(state, report))]
|
|
async fn relink_cluster_files(
|
|
state: &LauncherState,
|
|
cluster_id: i64,
|
|
folder_name: &str,
|
|
report: &mut RecoveryReport,
|
|
) -> LauncherResult<()> {
|
|
let pool = &state.services.db;
|
|
let content = state.services.content();
|
|
let cluster_root = paths::cluster_dir(folder_name)?;
|
|
|
|
for content_type in FILE_CONTENT_TYPES {
|
|
let dir = cluster_root.join(content_type.folder_name());
|
|
let files = match list_files(&dir).await {
|
|
Ok(files) => files,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
for file in files {
|
|
let file_name = file
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let enabled = !file_name.ends_with(".disabled");
|
|
let base_name = file_name.trim_end_matches(".disabled").to_string();
|
|
|
|
let row = match PackageStore::import_local_file(
|
|
&file,
|
|
content_type,
|
|
cluster_id,
|
|
&content,
|
|
)
|
|
.await
|
|
{
|
|
Ok(row) => row,
|
|
Err(err) => {
|
|
tracing::warn!(path = %file.display(), "failed to adopt cluster file: {err:#}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// `import_local_file` links under the artifact's own name which is
|
|
// the `.disabled` spelling for a file the user had switched off
|
|
if !enabled {
|
|
artifact_dao::update_cluster_artifact(pool, cluster_id, &row.hash, &base_name, 0)
|
|
.await?;
|
|
}
|
|
|
|
report.relinked_files += 1;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument(skip(state))]
|
|
pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<()> {
|
|
let clusters = state.clusters.list().await?;
|
|
|
|
for cluster in clusters {
|
|
let archives = match state
|
|
.bundles
|
|
.archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader)
|
|
.await
|
|
{
|
|
Ok(archives) => archives,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
cluster_id = cluster.id,
|
|
"failed to load bundle archives during recovery: {err:#}"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
for archive in archives {
|
|
let bundle_name = &archive.manifest.name;
|
|
for file in &archive.manifest.files {
|
|
use oneclient_content::bundles::BundleFileKind;
|
|
let BundleFileKind::Managed {
|
|
provider,
|
|
project_id,
|
|
version_id,
|
|
..
|
|
} = &file.kind
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let Some(release) = artifact_dao::get_provider_release(
|
|
&state.services.db,
|
|
*provider as i64,
|
|
project_id,
|
|
version_id,
|
|
)
|
|
.await?
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if artifact_dao::is_cluster_linked(&state.services.db, cluster.id, &release.hash)
|
|
.await?
|
|
{
|
|
cluster_bundle_dao::track_bundle_artifact(
|
|
&state.services.db,
|
|
cluster.id,
|
|
&release.hash,
|
|
bundle_name,
|
|
version_id,
|
|
project_id,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_folder_identity(folder_name: &str) -> (String, String, GameLoader) {
|
|
let tokens: Vec<&str> = folder_name.split_whitespace().collect();
|
|
|
|
for take in [2usize, 1] {
|
|
if tokens.len() > take {
|
|
let split = tokens.len() - take;
|
|
let loader_str = tokens[split..].join(" ");
|
|
if let Ok(loader) = loader_str.parse::<GameLoader>() {
|
|
let version = tokens[..split].join(" ");
|
|
if parse_mc_version(&version).is_some() {
|
|
return (folder_name.to_string(), version, loader);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mc_version = parse_mc_version(folder_name)
|
|
.map(|_| folder_name.to_string())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
(folder_name.to_string(), mc_version, GameLoader::Vanilla)
|
|
}
|
|
|
|
fn provider_from_dir_name(name: &str) -> Option<ProviderId> {
|
|
ProviderId::iter().find(|p| p.dir_name() == name)
|
|
}
|
|
|
|
fn relative_launcher_path(launcher_dir: &Path, abs: &Path) -> String {
|
|
abs.strip_prefix(launcher_dir)
|
|
.unwrap_or(abs)
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
}
|
|
|
|
async fn list_dir_names(dir: &Path) -> LauncherResult<Vec<String>> {
|
|
let mut names = Vec::new();
|
|
let Ok(mut entries) = polyio::read_dir(dir).await else {
|
|
return Ok(names);
|
|
};
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
|
|
names.push(entry.file_name().to_string_lossy().into_owned());
|
|
}
|
|
}
|
|
Ok(names)
|
|
}
|
|
|
|
async fn list_files(dir: &Path) -> LauncherResult<Vec<PathBuf>> {
|
|
let mut files = Vec::new();
|
|
let Ok(mut entries) = polyio::read_dir(dir).await else {
|
|
return Ok(files);
|
|
};
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
if entry.file_type().await.map(|t| t.is_file()).unwrap_or(false) {
|
|
files.push(entry.path());
|
|
}
|
|
}
|
|
Ok(files)
|
|
}
|