feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,894 @@
use crate::state::instances::{
ContentEntry, ContentSet, ContentSourceKind, InstanceFile,
adapters::sqlite::{content_rows, instance_rows},
};
use crate::state::{
CacheBehaviour, CachedEntry, ContentProviderRef, Dependency,
DependencyType, ModrinthVersionId, ProjectType, State, Version,
};
use crate::util::fetch::DownloadReason;
use futures::stream::{FuturesUnordered, StreamExt};
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use super::apply_content_install::{
DownloadedProjectVersion, add_downloaded_project_version,
add_project_from_version, add_resolved_content, archive_project_file,
content_ownership_for_path, download_project_version,
persist_resolved_plan_dependency_edges, remove_project,
resolve_content_scope, resolve_install_plan, toggle_disable_project,
};
use super::check_content_updates::{ContentUpdate, check_content_updates};
#[derive(Clone, Debug)]
struct BulkUpdatePlan {
project_updates: Vec<PlannedProjectUpdate>,
dependency_additions: Vec<PlannedDependencyInstall>,
curseforge_updates: Vec<ContentUpdate>,
}
#[derive(Clone, Debug)]
struct PlannedProjectUpdate {
relative_path: String,
project_id: String,
current_version_id: String,
update_version_id: String,
}
#[derive(Clone, Debug)]
struct PlannedDependencyInstall {
project_id: String,
version_id: String,
parent_version_id: String,
}
#[derive(Clone, Debug)]
enum PlannedDownload {
ProjectUpdate(PlannedProjectUpdate),
DependencyAddition(PlannedDependencyInstall),
}
enum DownloadedBulkProject {
ProjectUpdate(PlannedProjectUpdate, DownloadedProjectVersion),
DependencyAddition(PlannedDependencyInstall, DownloadedProjectVersion),
}
#[derive(Clone, Debug)]
struct AppliedBulkItem {
project_id: String,
version_id: String,
relative_path: String,
}
#[derive(Clone, Debug)]
struct InstalledProject {
relative_path: String,
project_id: Option<String>,
version_id: Option<String>,
enabled: bool,
}
#[derive(Clone, Debug)]
struct ResolvedDependency {
project_id: String,
version_id: String,
parent_version_id: String,
}
pub(crate) async fn update_project(
instance_id: &str,
project_path: &str,
state: &State,
) -> crate::Result<String> {
let updates = check_content_updates(
instance_id,
Some(CacheBehaviour::MustRevalidate),
state,
)
.await?;
let update = updates
.into_iter()
.find(|update| update.relative_path() == project_path)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"This project cannot be updated!".to_string(),
)
})?;
apply_content_update(instance_id, project_path, &update, state).await
}
async fn apply_content_update(
instance_id: &str,
project_path: &str,
update: &ContentUpdate,
state: &State,
) -> crate::Result<String> {
let mut new_path = match update {
ContentUpdate::Modrinth {
project_id,
current_version_id,
update_version_id,
..
} => {
let version = CachedEntry::get_version(
update_version_id,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unable to install version id {}. Not found.",
update_version_id
))
})?;
let content_type =
ProjectType::get_from_loaders(version.loaders.clone())
.map(modrinth_content_management::ContentType::from)
.unwrap_or(modrinth_content_management::ContentType::Mod);
let plan = resolve_install_plan(
instance_id,
super::apply_content_install::InstanceInstallProjectRequest {
project_id: project_id.to_string(),
version_id: Some(update_version_id.to_string()),
content_type,
selected: Default::default(),
excluded_project_ids: Vec::new(),
force_project_ids: Vec::new(),
},
state,
)
.await?;
let ownership_kind =
content_ownership_for_path(instance_id, project_path, state)
.await?;
let mut paths = Vec::with_capacity(plan.dependencies.len() + 1);
paths.push(
add_project_from_version(
instance_id,
&plan.primary.version_id,
DownloadReason::Update,
Some(current_version_id.to_string()),
ContentSourceKind::Local,
ownership_kind,
state,
)
.await?,
);
for dependency in &plan.dependencies {
paths.push(
add_resolved_content(
instance_id,
dependency,
DownloadReason::Dependency,
true,
state,
)
.await?,
);
}
persist_resolved_plan_dependency_edges(
instance_id,
&paths,
&plan,
state,
)
.await?;
paths.remove(0)
}
ContentUpdate::CurseForge { .. } => {
let result = crate::api::curseforge::update_installed_file(
instance_id,
project_path,
)
.await?;
result
.installed
.into_iter()
.find(|file| !file.dependency)
.map(|file| file.relative_path)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"CurseForge update did not produce an installed file"
.to_string(),
)
})?
}
};
if project_path.ends_with(".disabled") {
new_path =
toggle_disable_project(instance_id, &new_path, Some(false), state)
.await?;
}
if new_path != project_path {
if archive_project_file(instance_id, project_path, &new_path, state)
.await?
.is_none()
{
remove_project(instance_id, project_path, state).await?;
}
}
Ok(new_path)
}
pub(crate) async fn update_all_projects(
instance_id: &str,
state: &State,
) -> crate::Result<HashMap<String, String>> {
emit_bulk_update_progress(
instance_id,
crate::event::InstanceBulkUpdateProgressStage::ResolvingVersions,
0,
0,
)
.await?;
let plan = plan_bulk_update(instance_id, state).await?;
let download_total =
plan.project_updates.len() + plan.dependency_additions.len();
let downloads =
download_planned_projects(instance_id, &plan, download_total, state)
.await?;
let mut changed = HashMap::new();
let mut applied = Vec::<AppliedBulkItem>::new();
emit_bulk_update_progress(
instance_id,
crate::event::InstanceBulkUpdateProgressStage::Finishing,
download_total,
download_total,
)
.await?;
for download in downloads {
match download {
DownloadedBulkProject::ProjectUpdate(update, downloaded) => {
let mut new_path = add_downloaded_project_version(
instance_id,
downloaded,
ContentSourceKind::Local,
crate::state::instances::ContentOwnershipKind::UserAdded,
state,
)
.await?;
if update.relative_path.ends_with(".disabled") {
new_path = toggle_disable_project(
instance_id,
&new_path,
Some(false),
state,
)
.await?;
}
if new_path != update.relative_path {
if archive_project_file(
instance_id,
&update.relative_path,
&new_path,
state,
)
.await?
.is_none()
{
remove_project(
instance_id,
&update.relative_path,
state,
)
.await?;
}
}
applied.push(AppliedBulkItem {
project_id: update.project_id,
version_id: update.update_version_id,
relative_path: new_path.clone(),
});
changed.insert(update.relative_path, new_path);
}
DownloadedBulkProject::DependencyAddition(
dependency,
downloaded,
) => {
let new_path = add_downloaded_project_version(
instance_id,
downloaded,
ContentSourceKind::Local,
crate::state::instances::ContentOwnershipKind::UserAdded,
state,
)
.await?;
let scope =
resolve_content_scope(instance_id, None, state).await?;
if let Some(entry) =
content_rows::get_content_entry_by_relative_path(
&scope.content_set_id,
&new_path,
&state.pool,
)
.await?
{
content_rows::set_content_entry_auto_dependency(
&entry.id,
true,
&state.pool,
)
.await?;
}
applied.push(AppliedBulkItem {
project_id: dependency.project_id,
version_id: dependency.version_id,
relative_path: new_path,
});
}
}
}
for update in &plan.curseforge_updates {
let relative_path = update.relative_path().to_string();
let new_path =
apply_content_update(instance_id, &relative_path, update, state)
.await?;
changed.insert(relative_path, new_path);
}
persist_bulk_dependency_edges(
instance_id,
&applied,
&plan.dependency_additions,
state,
)
.await?;
Ok(changed)
}
async fn persist_bulk_dependency_edges(
instance_id: &str,
applied: &[AppliedBulkItem],
dependency_additions: &[PlannedDependencyInstall],
state: &State,
) -> crate::Result<()> {
if dependency_additions.is_empty() {
return Ok(());
}
let _instance_lock = state.lock_instance_content(instance_id).await;
let scope = resolve_content_scope(instance_id, None, state).await?;
let mut tx = state.pool.begin().await?;
for dependency in dependency_additions {
let Some(parent) = applied
.iter()
.find(|item| item.version_id == dependency.parent_version_id)
else {
continue;
};
let Some(child) = applied
.iter()
.find(|item| item.version_id == dependency.version_id)
else {
continue;
};
let Some(parent_entry) =
content_rows::get_content_entry_by_relative_path(
&scope.content_set_id,
&parent.relative_path,
&state.pool,
)
.await?
else {
continue;
};
let Some(child_entry) =
content_rows::get_content_entry_by_relative_path(
&scope.content_set_id,
&child.relative_path,
&state.pool,
)
.await?
else {
continue;
};
let now = chrono::Utc::now();
content_rows::upsert_content_dependency_edge_in_transaction(
&crate::state::instances::ContentDependencyEdge {
id: format!("content-dependency:{}", uuid::Uuid::new_v4()),
content_set_id: scope.content_set_id.clone(),
parent_entry_id: parent_entry.id,
child_entry_id: child_entry.id,
evidence_provider: crate::state::ContentProvider::Modrinth,
parent_provider: crate::state::ContentProvider::Modrinth,
child_provider: crate::state::ContentProvider::Modrinth,
dependency_kind:
crate::state::instances::ContentDependencyKind::Required,
parent_project_id: parent.project_id.clone(),
parent_release_id: parent.version_id.clone(),
child_project_id: child.project_id.clone(),
child_release_id: child.version_id.clone(),
created_at: now,
modified_at: now,
},
&mut tx,
)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn download_planned_projects(
instance_id: &str,
plan: &BulkUpdatePlan,
total: usize,
state: &State,
) -> crate::Result<Vec<DownloadedBulkProject>> {
emit_bulk_update_progress(
instance_id,
crate::event::InstanceBulkUpdateProgressStage::Downloading,
0,
total,
)
.await?;
let mut downloads = plan
.project_updates
.iter()
.cloned()
.map(PlannedDownload::ProjectUpdate)
.chain(
plan.dependency_additions
.iter()
.cloned()
.map(PlannedDownload::DependencyAddition),
)
.map(|download| async move {
match download {
PlannedDownload::ProjectUpdate(update) => {
let downloaded = download_project_version(
instance_id,
&update.update_version_id,
DownloadReason::Update,
Some(update.current_version_id.clone()),
state,
)
.await?;
Ok::<_, crate::Error>(DownloadedBulkProject::ProjectUpdate(
update, downloaded,
))
}
PlannedDownload::DependencyAddition(dependency) => {
let downloaded = download_project_version(
instance_id,
&dependency.version_id,
DownloadReason::Dependency,
Some(dependency.parent_version_id.clone()),
state,
)
.await?;
Ok::<_, crate::Error>(
DownloadedBulkProject::DependencyAddition(
dependency, downloaded,
),
)
}
}
})
.collect::<FuturesUnordered<_>>();
let mut completed = 0;
let mut output = Vec::with_capacity(total);
while let Some(download) = downloads.next().await {
let download = download?;
completed += 1;
emit_bulk_update_progress(
instance_id,
crate::event::InstanceBulkUpdateProgressStage::Downloading,
completed,
total,
)
.await?;
output.push(download);
}
Ok(output)
}
async fn emit_bulk_update_progress(
instance_id: &str,
stage: crate::event::InstanceBulkUpdateProgressStage,
current: usize,
total: usize,
) -> crate::Result<()> {
crate::event::emit::emit_instance_bulk_update_progress(
crate::event::InstanceBulkUpdateProgressPayload {
instance_id: instance_id.to_string(),
stage,
current,
total,
},
)
.await
}
async fn plan_bulk_update(
instance_id: &str,
state: &State,
) -> crate::Result<BulkUpdatePlan> {
let updateable_paths =
bulk_updateable_project_paths(instance_id, state).await?;
if updateable_paths.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
curseforge_updates: Vec::new(),
});
}
let updates = check_content_updates(
instance_id,
Some(CacheBehaviour::MustRevalidate),
state,
)
.await?
.into_iter()
.filter(|update| updateable_paths.contains(update.relative_path()))
.collect::<Vec<_>>();
if updates.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
curseforge_updates: Vec::new(),
});
}
let content_set =
content_rows::get_applied_content_set(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Instance {instance_id} has no applied content set"
))
})?;
let installed =
installed_projects(instance_id, &content_set, state).await?;
let installed_by_project = installed
.iter()
.filter_map(|project| {
project
.project_id
.as_ref()
.map(|project_id| (project_id.clone(), project.clone()))
})
.collect::<HashMap<_, _>>();
let updates_by_path = updates
.iter()
.filter_map(|update| {
let (_, current, target) = update.modrinth_ids()?;
Some((
update.relative_path().to_string(),
(current.to_string(), target.to_string()),
))
})
.collect::<HashMap<_, _>>();
let version_ids = installed
.iter()
.filter(|project| updateable_paths.contains(&project.relative_path))
.filter_map(|project| project.version_id.clone())
.chain(updates.iter().filter_map(|update| {
update
.modrinth_ids()
.map(|(_, _, target)| target.to_string())
}))
.collect::<HashSet<_>>();
let version_id_refs = version_ids
.iter()
.map(|id| ModrinthVersionId::new(id.clone()))
.collect::<crate::Result<Vec<_>>>()?;
let versions = CachedEntry::get_version_many(
&version_id_refs,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
let versions_by_id = versions
.into_iter()
.map(|version| (version.id.clone(), version))
.collect::<HashMap<_, _>>();
let planned_versions = installed
.iter()
.filter(|project| project.enabled)
.filter(|project| updateable_paths.contains(&project.relative_path))
.filter_map(|project| {
let target_version_id = updates_by_path
.get(&project.relative_path)
.map(|(_, update_version_id)| update_version_id)
.map(|version_id| version_id.as_str())
.or(project.version_id.as_deref())?;
versions_by_id.get(target_version_id).cloned()
})
.collect::<Vec<_>>();
let planned_dependencies =
dependency_closure(planned_versions, &content_set, state).await?;
let dependency_additions = planned_dependencies
.values()
.filter(|dependency| {
!installed_by_project.contains_key(&dependency.project_id)
})
.map(|dependency| PlannedDependencyInstall {
project_id: dependency.project_id.clone(),
version_id: dependency.version_id.clone(),
parent_version_id: dependency.parent_version_id.clone(),
})
.collect::<Vec<_>>();
let curseforge_updates = updates
.iter()
.filter(|update| matches!(update, ContentUpdate::CurseForge { .. }))
.cloned()
.collect::<Vec<_>>();
let project_updates = updates
.into_iter()
.filter_map(|update| {
let (project_id, current, target) = update.modrinth_ids()?;
Some(PlannedProjectUpdate {
relative_path: update.relative_path().to_string(),
project_id: project_id.to_string(),
current_version_id: current.to_string(),
update_version_id: target.to_string(),
})
})
.collect::<Vec<_>>();
Ok(BulkUpdatePlan {
project_updates,
dependency_additions,
curseforge_updates,
})
}
async fn bulk_updateable_project_paths(
instance_id: &str,
state: &State,
) -> crate::Result<HashSet<String>> {
let paths = sqlx::query_scalar::<_, String>(
"SELECT file.relative_path
FROM instance_content_entries entry
INNER JOIN instance_files file ON file.id = entry.file_id
INNER JOIN instances instance ON instance.id = entry.instance_id
WHERE instance.id = ?
AND entry.content_set_id = instance.applied_content_set_id
AND entry.ownership_kind = 'user_added'
AND file.missing = 0",
)
.bind(instance_id)
.fetch_all(&state.pool)
.await?;
Ok(paths.into_iter().collect())
}
async fn installed_projects(
instance_id: &str,
content_set: &ContentSet,
state: &State,
) -> crate::Result<Vec<InstalledProject>> {
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let entries =
content_rows::get_content_entries(&content_set.id, &state.pool).await?;
let entries_by_file_id = entries
.iter()
.filter_map(|entry| {
entry.file_id.as_deref().map(|file_id| (file_id, entry))
})
.collect::<HashMap<_, _>>();
let files =
content_rows::get_instance_files(&instance.id, &state.pool).await?;
let mut installed = Vec::new();
for file in files {
let Some(entry) = entries_by_file_id.get(file.id.as_str()) else {
continue;
};
let refs =
content_rows::get_content_provider_refs(&entry.id, &state.pool)
.await?;
if let Some(project) = installed_project_from_row(&file, entry, &refs) {
installed.push(project);
}
}
Ok(installed)
}
fn installed_project_from_row(
file: &InstanceFile,
entry: &ContentEntry,
provider_refs: &[ContentProviderRef],
) -> Option<InstalledProject> {
let (project_id, version_id) =
provider_refs.iter().find_map(|reference| match reference {
ContentProviderRef::Modrinth {
project_id,
version_id: Some(version_id),
} => Some((
Some(project_id.to_string()),
Some(version_id.to_string()),
)),
_ => None,
})?;
Some(InstalledProject {
relative_path: file.relative_path.clone(),
project_id,
version_id,
enabled: entry.enabled && file.enabled,
})
}
async fn dependency_closure(
root_versions: Vec<Version>,
content_set: &ContentSet,
state: &State,
) -> crate::Result<HashMap<String, ResolvedDependency>> {
let mut output = HashMap::new();
let mut stack = root_versions;
let mut visited_versions = HashSet::new();
let mut version_cache = HashMap::new();
let mut project_versions_cache = HashMap::new();
while let Some(version) = stack.pop() {
if !visited_versions.insert(version.id.clone()) {
continue;
}
for dependency in &version.dependencies {
if !is_required_dependency(dependency, content_set) {
continue;
}
let Some(dependency_version) = resolve_dependency_version(
dependency,
content_set,
state,
&mut version_cache,
&mut project_versions_cache,
)
.await?
else {
continue;
};
let project_id = dependency
.project_id
.clone()
.unwrap_or_else(|| dependency_version.project_id.clone());
output.entry(project_id.clone()).or_insert_with(|| {
ResolvedDependency {
project_id,
version_id: dependency_version.id.clone(),
parent_version_id: version.id.clone(),
}
});
stack.push(dependency_version);
}
}
Ok(output)
}
fn is_required_dependency(
dependency: &Dependency,
content_set: &ContentSet,
) -> bool {
matches!(dependency.dependency_type, DependencyType::Required)
&& !(dependency.project_id.as_deref() == Some("P7dR8mSH")
&& content_set.loader.as_str() == "quilt")
}
async fn resolve_dependency_version(
dependency: &Dependency,
content_set: &ContentSet,
state: &State,
version_cache: &mut HashMap<String, Option<Version>>,
project_versions_cache: &mut HashMap<String, Option<Vec<Version>>>,
) -> crate::Result<Option<Version>> {
if let Some(version_id) = &dependency.version_id {
return cached_version(version_id, version_cache, state).await;
}
let Some(project_id) = &dependency.project_id else {
return Ok(None);
};
let Some(mut versions) =
cached_project_versions(project_id, project_versions_cache, state)
.await?
else {
return Ok(None);
};
versions.sort_by_key(|version| Reverse(version.date_published));
Ok(find_preferred_dependency_version(&versions, content_set))
}
async fn cached_version(
version_id: &str,
version_cache: &mut HashMap<String, Option<Version>>,
state: &State,
) -> crate::Result<Option<Version>> {
if !version_cache.contains_key(version_id) {
let version = CachedEntry::get_version(
&ModrinthVersionId::new(version_id.to_string())?,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
version_cache.insert(version_id.to_string(), version);
}
Ok(version_cache.get(version_id).cloned().flatten())
}
async fn cached_project_versions(
project_id: &str,
project_versions_cache: &mut HashMap<String, Option<Vec<Version>>>,
state: &State,
) -> crate::Result<Option<Vec<Version>>> {
if !project_versions_cache.contains_key(project_id) {
let versions = CachedEntry::get_project_versions(
&crate::state::ModrinthProjectId::new(project_id.to_string())?,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
project_versions_cache.insert(project_id.to_string(), versions);
}
Ok(project_versions_cache.get(project_id).cloned().flatten())
}
fn find_preferred_dependency_version(
versions: &[Version],
content_set: &ContentSet,
) -> Option<Version> {
versions
.iter()
.find(|version| {
version.game_versions.contains(&content_set.game_version)
&& version
.loaders
.iter()
.any(|loader| loader == content_set.loader.as_str())
})
.or_else(|| {
versions.iter().find(|version| {
is_dependency_version_compatible(version, content_set)
})
})
.cloned()
}
fn is_dependency_version_compatible(
version: &Version,
content_set: &ContentSet,
) -> bool {
version.game_versions.contains(&content_set.game_version)
&& (version
.loaders
.iter()
.any(|loader| loader == content_set.loader.as_str())
|| version.loaders.iter().any(|loader| loader == "datapack"))
}
@@ -0,0 +1,485 @@
use crate::state::instances::{
ContentEntry, ContentSet, Instance, InstanceFile,
adapters::sqlite::{content_rows, instance_rows},
};
use crate::state::{
CacheBehaviour, CachedEntry, ContentProvider, ContentProviderRef,
CurseForgeFileId, ModrinthProjectId, ModrinthVersionId, ProjectType,
ReleaseChannel, State,
};
use std::collections::HashMap;
use super::sync_content_files::{
fetch_content_file_updates, installed_modrinth_version_id,
modrinth_update_enabled, project_type_for_file,
sync_instance_content_files,
};
#[derive(Clone, Debug)]
pub(crate) enum ContentUpdate {
Modrinth {
relative_path: String,
project_id: ModrinthProjectId,
current_version_id: ModrinthVersionId,
update_version_id: ModrinthVersionId,
},
CurseForge {
relative_path: String,
},
}
impl ContentUpdate {
pub fn relative_path(&self) -> &str {
match self {
Self::Modrinth { relative_path, .. }
| Self::CurseForge { relative_path, .. } => relative_path,
}
}
pub fn modrinth_ids(
&self,
) -> Option<(&ModrinthProjectId, &ModrinthVersionId, &ModrinthVersionId)>
{
match self {
Self::Modrinth {
project_id,
current_version_id,
update_version_id,
..
} => Some((project_id, current_version_id, update_version_id)),
Self::CurseForge { .. } => None,
}
}
}
#[derive(Clone, Debug)]
struct UpdateCandidate {
entry: Option<ContentEntry>,
file: InstanceFile,
project_type: ProjectType,
project_id: ModrinthProjectId,
current_version_id: ModrinthVersionId,
}
pub(crate) async fn check_content_updates(
instance_id: &str,
cache_behaviour: Option<CacheBehaviour>,
state: &State,
) -> crate::Result<Vec<ContentUpdate>> {
let context = load_installed_content(instance_id, state).await?;
let candidates =
modrinth_update_candidates(&context, cache_behaviour, state).await?;
let mut output =
resolve_modrinth_updates(&context, &candidates, cache_behaviour, state)
.await?;
output.extend(resolve_curseforge_updates(&context, state).await?);
Ok(output)
}
/// Everything about an instance's currently applied content, loaded once and
/// shared across the update-check phases.
struct InstalledContentContext {
instance: Instance,
content_set: ContentSet,
files: Vec<InstanceFile>,
files_by_id: HashMap<String, InstanceFile>,
entries_by_file_id: HashMap<String, ContentEntry>,
provider_refs_by_file_id: HashMap<String, Vec<ContentProviderRef>>,
origin_provider_by_file_id: HashMap<String, Option<ContentProvider>>,
}
/// Phase 1 — load the instance's applied content set and file/provider state.
async fn load_installed_content(
instance_id: &str,
state: &State,
) -> crate::Result<InstalledContentContext> {
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let files = sync_instance_content_files(&instance, state).await?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let content_set =
content_rows::get_applied_content_set(&instance.id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Instance {} has no applied content set",
instance.id
))
})?;
let entries =
content_rows::get_content_entries(&content_set.id, &state.pool).await?;
let entries_by_file_id = entries
.iter()
.filter_map(|entry| {
entry
.file_id
.as_deref()
.map(|file_id| (file_id.to_string(), entry.clone()))
})
.collect();
let files_by_id = files
.iter()
.map(|file| (file.id.clone(), file.clone()))
.collect();
let mut provider_refs_by_file_id = HashMap::new();
let mut origin_provider_by_file_id = HashMap::new();
for entry in &entries {
let Some(file_id) = entry.file_id.as_deref() else {
continue;
};
provider_refs_by_file_id.insert(
file_id.to_string(),
content_rows::get_content_provider_refs(&entry.id, &state.pool)
.await?,
);
origin_provider_by_file_id.insert(
file_id.to_string(),
content_rows::get_content_origin_provider(&entry.id, &state.pool)
.await?,
);
}
Ok(InstalledContentContext {
instance,
content_set,
files,
files_by_id,
entries_by_file_id,
provider_refs_by_file_id,
origin_provider_by_file_id,
})
}
/// Phase 2 — resolve the Modrinth version of every update-enabled file and
/// build an `UpdateCandidate` for each one that maps to a known project.
async fn modrinth_update_candidates(
context: &InstalledContentContext,
cache_behaviour: Option<CacheBehaviour>,
state: &State,
) -> crate::Result<Vec<UpdateCandidate>> {
let hashes = context
.files
.iter()
.filter(|file| {
modrinth_update_enabled(
context
.origin_provider_by_file_id
.get(&file.id)
.and_then(|provider| *provider),
context
.provider_refs_by_file_id
.get(&file.id)
.map(Vec::as_slice)
.unwrap_or_default(),
)
})
.map(|file| file.sha1.as_str())
.collect::<Vec<_>>();
let file_info = CachedEntry::get_file_many(
&hashes,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
let file_info_by_hash = file_info
.into_iter()
.map(|file| (file.hash.clone(), file))
.collect::<HashMap<_, _>>();
let mut candidates = Vec::new();
for file in &context.files {
if !modrinth_update_enabled(
context
.origin_provider_by_file_id
.get(&file.id)
.and_then(|provider| *provider),
context
.provider_refs_by_file_id
.get(&file.id)
.map(Vec::as_slice)
.unwrap_or_default(),
) {
continue;
}
let Some(metadata) = file_info_by_hash.get(&file.sha1) else {
continue;
};
let Some(project_type) = project_type_for_file(file) else {
continue;
};
let provider_refs = context
.provider_refs_by_file_id
.get(&file.id)
.map(Vec::as_slice)
.unwrap_or_default();
let project_id = ModrinthProjectId::new(metadata.project_id.clone())?;
let current_version_id = installed_modrinth_version_id(provider_refs)
.unwrap_or(ModrinthVersionId::new(metadata.version_id.clone())?);
candidates.push(UpdateCandidate {
entry: context.entries_by_file_id.get(&file.id).cloned(),
file: file.clone(),
project_type,
project_id,
current_version_id,
});
}
Ok(candidates)
}
/// Phase 3 — query available Modrinth updates for the candidates, persist the
/// check result, and collect the differing versions.
async fn resolve_modrinth_updates(
context: &InstalledContentContext,
candidates: &[UpdateCandidate],
cache_behaviour: Option<CacheBehaviour>,
state: &State,
) -> crate::Result<Vec<ContentUpdate>> {
let mut output = Vec::new();
if candidates.is_empty() {
return Ok(output);
}
let installed_channels =
installed_update_channels(candidates, cache_behaviour, state).await?;
let update_keys = candidates
.iter()
.map(|candidate| {
update_cache_key(
&candidate.file,
candidate.project_type,
effective_update_channel(
context.instance.update_channel,
installed_channels.get(&candidate.file.sha1).copied(),
),
&context.content_set.game_version,
context.content_set.loader.as_str(),
)
})
.collect::<Vec<_>>();
let update_key_refs = update_keys
.iter()
.map(|key| key.as_str())
.collect::<Vec<_>>();
let updates = fetch_content_file_updates(
&update_key_refs,
cache_behaviour,
true,
&state.pool,
&state.api_semaphore,
)
.await?;
let mut updates_by_hash: HashMap<String, Vec<String>> = HashMap::new();
for update in updates {
updates_by_hash
.entry(update.hash)
.or_default()
.push(update.update_version_id);
}
let _instance_lock =
state.lock_instance_content(&context.instance.id).await;
for candidate in candidates {
let update_version_id = updates_by_hash
.remove(&candidate.file.sha1)
.unwrap_or_default()
.into_iter()
.find(|update_version_id| {
update_version_id != candidate.current_version_id.as_str()
})
.map(ModrinthVersionId::new)
.transpose()?;
if let Some(entry) = &candidate.entry {
content_rows::upsert_content_update_check(
&entry.id,
context.instance.update_channel,
Some(ContentProvider::Modrinth),
Some(candidate.project_id.as_str()),
update_version_id.as_ref().map(ModrinthVersionId::as_str),
&state.pool,
)
.await?;
}
if let Some(update_version_id) = update_version_id {
output.push(ContentUpdate::Modrinth {
relative_path: candidate.file.relative_path.clone(),
project_id: candidate.project_id.clone(),
current_version_id: candidate.current_version_id.clone(),
update_version_id,
});
}
}
Ok(output)
}
/// Phase 3 — resolve CurseForge updates for origin-CurseForge content.
async fn resolve_curseforge_updates(
context: &InstalledContentContext,
state: &State,
) -> crate::Result<Vec<ContentUpdate>> {
let mut output = Vec::new();
if crate::api::curseforge::capability().status
!= crate::api::curseforge::CurseForgeCapabilityStatus::Ready
{
return Ok(output);
}
let _instance_lock =
state.lock_instance_content(&context.instance.id).await;
let mut target_files_by_project = HashMap::<u32, Option<u32>>::new();
for (file_id, refs) in &context.provider_refs_by_file_id {
if context
.origin_provider_by_file_id
.get(file_id)
.and_then(|provider| *provider)
!= Some(ContentProvider::CurseForge)
{
continue;
}
let Some(ContentProviderRef::CurseForge {
project_id,
file_id: Some(current_file_id),
}) = refs.iter().find(|reference| {
matches!(reference, ContentProviderRef::CurseForge { .. })
})
else {
continue;
};
let Some(instance_file) = context.files_by_id.get(file_id) else {
continue;
};
let Some(project_type) = project_type_for_file(instance_file) else {
continue;
};
let target_file_id =
match target_files_by_project.get(&project_id.get()) {
Some(target_file_id) => *target_file_id,
None => {
let target_file_id =
crate::api::curseforge::select_latest_compatible_file(
project_id.get(),
Some(context.content_set.game_version.clone()),
(project_type == ProjectType::Mod)
.then(|| {
curseforge_loader_type(
context.content_set.loader.as_str(),
)
})
.flatten(),
Some(context.instance.update_channel),
)
.await?
.map(|file| file.id);
target_files_by_project
.insert(project_id.get(), target_file_id);
target_file_id
}
};
let Some(target_file_id) = target_file_id else {
continue;
};
if target_file_id == current_file_id.get() {
continue;
}
let target_file_id = CurseForgeFileId::new(target_file_id)?;
if let Some(entry) = context.entries_by_file_id.get(file_id) {
let project_id_string = project_id.get().to_string();
let target_file_id_string = target_file_id.get().to_string();
content_rows::upsert_content_update_check(
&entry.id,
context.instance.update_channel,
Some(ContentProvider::CurseForge),
Some(&project_id_string),
Some(&target_file_id_string),
&state.pool,
)
.await?;
}
output.push(ContentUpdate::CurseForge {
relative_path: instance_file.relative_path.clone(),
});
}
Ok(output)
}
fn curseforge_loader_type(loader: &str) -> Option<u32> {
match loader {
"forge" => Some(1),
"fabric" => Some(4),
"quilt" => Some(5),
"neoforge" => Some(6),
_ => None,
}
}
async fn installed_update_channels(
candidates: &[UpdateCandidate],
cache_behaviour: Option<CacheBehaviour>,
state: &State,
) -> crate::Result<HashMap<String, ReleaseChannel>> {
let version_ids = candidates
.iter()
.filter_map(|candidate| Some(candidate.current_version_id.clone()))
.collect::<Vec<_>>();
let versions = CachedEntry::get_version_many(
&version_ids,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
let channels_by_version_id = versions
.into_iter()
.map(|version| {
(
version.id,
ReleaseChannel::from_version_type(&version.version_type),
)
})
.collect::<HashMap<_, _>>();
Ok(candidates
.iter()
.filter_map(|candidate| {
channels_by_version_id
.get(candidate.current_version_id.as_str())
.copied()
.map(|channel| (candidate.file.sha1.clone(), channel))
})
.collect())
}
fn effective_update_channel(
preferred: ReleaseChannel,
installed: Option<ReleaseChannel>,
) -> ReleaseChannel {
installed.map_or(preferred, |channel| preferred.least_stable(channel))
}
fn update_cache_key(
file: &InstanceFile,
project_type: ProjectType,
channel: ReleaseChannel,
game_version: &str,
loader: &str,
) -> String {
format!(
"{}-{}-{}-{}",
file.sha1,
if project_type == ProjectType::Mod {
loader.to_string()
} else {
project_type.get_loaders().join("+")
},
channel.key(),
game_version
)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,339 @@
use crate::api::pack::import::{
ImportLauncherType,
direct_link::{direct_link_group, resolve_direct_link},
};
use crate::launcher::ExternalGameDirMode;
use crate::state::instances::{
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
InstanceLaunchOverrides, InstanceLink, LoaderComponent,
adapters::sqlite::{content_rows, instance_rows, loader_component_rows},
};
use crate::state::{
InstanceInstallStage, LauncherFeatureVersion, ReleaseChannel, State,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tracing::info;
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateDirectLinkInstance {
/// Display name; defaults to the actual version JSON stem.
pub name: Option<String>,
/// Same launcher identity used by the existing import API.
pub launcher_type: ImportLauncherType,
/// Root selected for launcher scanning/import.
pub base_path: PathBuf,
/// Scanned launcher instance name/folder identity.
pub instance_folder: String,
/// Pre-resolved version directory, including compatible-mode selections.
#[serde(default)]
pub instance_path: Option<String>,
#[serde(default)]
pub game_dir_mode: Option<ExternalGameDirMode>,
}
pub(crate) async fn create_direct_link_instance(
input: CreateDirectLinkInstance,
state: &State,
) -> crate::Result<Instance> {
let resolved = resolve_direct_link(
input.launcher_type,
input.base_path,
input.instance_folder,
input.instance_path,
)
.await?;
info!(
launcher = resolved.launcher_key(),
version_id = %resolved.version_id,
version_json = %resolved.version_json.display(),
"Creating directly associated instance"
);
let name = input
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| resolved.version_id.clone());
// Reserves a unique relative path for the instance record only; no
// profile directory is ever created for directly associated instances.
let (path, _full_path) =
super::create_instance::resolve_instance_path(&name, None, state)
.await?;
let now = Utc::now();
let instance_id = format!("local:{}", Uuid::new_v4());
let content_set_id = format!("content-set:{}", Uuid::new_v4());
let instance = Instance {
id: instance_id.clone(),
path: path.clone(),
applied_content_set_id: Some(content_set_id.clone()),
// Nothing to install: every file already sits in place inside the
// linked `.minecraft`, so the instance is launchable immediately.
install_stage: InstanceInstallStage::Installed,
launcher_feature_version: LauncherFeatureVersion::MOST_RECENT,
update_channel: ReleaseChannel::Release,
name,
icon_path: None,
symlink_target: None,
linked_launcher: Some(resolved.launcher_key().to_string()),
linked_launcher_root: Some(
resolved.launcher_root.to_string_lossy().to_string(),
),
linked_dot_minecraft: Some(
resolved.dot_minecraft.to_string_lossy().to_string(),
),
linked_version_id: Some(resolved.version_id.clone()),
linked_version_json_path: Some(
resolved.version_json.to_string_lossy().to_string(),
),
linked_game_dir_mode: input
.game_dir_mode
.map(|mode| mode.key().to_string()),
// Directly associated instances resolve their game directory from the
// link metadata; the managed `game_dir_override` never applies.
game_dir_override: None,
created: now,
modified: now,
last_played: None,
pinned_at: None,
submitted_time_played: 0,
recent_time_played: 0,
};
let content_set = ContentSet {
id: content_set_id.clone(),
instance_id: instance_id.clone(),
name: "Default".to_string(),
source_kind: ContentSourceKind::Local,
status: ContentSetStatus::Available,
game_version: resolved.game_version,
protocol_version: None,
loader: resolved.loader,
// The loader is installed and managed by the external launcher; the
// version parsed from the version JSON is not a reliable display value
// for directly associated instances, so it is intentionally nulled.
loader_version: None,
revision: 0,
created: now,
modified: now,
};
let launch_overrides = InstanceLaunchOverrides::empty(instance_id.clone());
let mut tx = state.pool.begin().await?;
instance_rows::insert_instance(&instance, &mut tx).await?;
instance_rows::set_direct_link_fields(
&instance.id,
&instance_rows::DirectLinkFields {
launcher: instance.linked_launcher.clone(),
launcher_root: instance.linked_launcher_root.clone(),
dot_minecraft: instance.linked_dot_minecraft.clone(),
version_id: instance.linked_version_id.clone(),
version_json_path: instance.linked_version_json_path.clone(),
game_dir_mode: instance.linked_game_dir_mode.clone(),
},
&mut tx,
)
.await?;
content_rows::insert_content_set(&content_set, &mut tx).await?;
loader_component_rows::replace_loader_components(
&instance_id,
&LoaderComponent::from_legacy_projection(
instance_id.clone(),
resolved.loader,
None,
),
&mut tx,
)
.await?;
instance_rows::upsert_instance_link(
&instance_id,
&InstanceLink::Unmanaged,
&mut tx,
)
.await?;
let groups = direct_link_group(&resolved.dot_minecraft)
.into_iter()
.collect::<Vec<_>>();
instance_rows::replace_instance_groups(&instance_id, &groups, &mut tx)
.await?;
instance_rows::upsert_instance_launch_overrides(&launch_overrides, &mut tx)
.await?;
tx.commit().await?;
// Deliberately no config sync and no folder watcher: both would write
// into or monitor folders outside of Axolotl's own directories.
Ok(instance)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{DirectoryInfo, ModLoader};
use serde_json::json;
use sqlx::sqlite::SqlitePoolOptions;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
fn write_version(
root: &Path,
folder: &str,
json_stem: &str,
value: serde_json::Value,
) -> std::io::Result<PathBuf> {
let dir = root.join("versions").join(folder);
std::fs::create_dir_all(&dir)?;
let json_path = dir.join(format!("{json_stem}.json"));
std::fs::write(&json_path, serde_json::to_vec_pretty(&value).unwrap())?;
Ok(json_path)
}
async fn test_state_with_pool() -> crate::Result<(TempDir, Arc<State>)> {
let temp = TempDir::new()?;
let dirs = DirectoryInfo {
settings_dir: temp.path().join("settings"),
config_dir: temp.path().join("config"),
app_identifier: "test".to_string(),
};
std::fs::create_dir_all(dirs.instances_dir())?;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await?;
sqlx::migrate!().run(&pool).await?;
Ok((temp, crate::state::test_state(dirs, pool).await?))
}
#[tokio::test]
async fn persists_resolved_direct_link_fields_without_creating_profile()
-> crate::Result<()> {
let (_temp, state) = test_state_with_pool().await?;
let minecraft = TempDir::new()?;
let json_path = write_version(
minecraft.path(),
"ui-folder",
"1.20.1-forge",
json!({
"id": "1.20.1-forge",
"inheritsFrom": "1.20.1",
"mainClass": "forge.Main",
"libraries": [
{ "name": "net.minecraftforge:forge:1.20.1-47.4.0" }
]
}),
)?;
let source_before = std::fs::read(&json_path)?;
let instance = create_direct_link_instance(
CreateDirectLinkInstance {
name: Some("My Forge".to_string()),
launcher_type: ImportLauncherType::Generic,
base_path: minecraft.path().to_path_buf(),
instance_folder: "versions/ui-folder".to_string(),
instance_path: None,
game_dir_mode: None,
},
&state,
)
.await?;
assert_eq!(instance.name, "My Forge");
assert_eq!(instance.linked_launcher.as_deref(), Some("generic"));
assert_eq!(instance.linked_version_id.as_deref(), Some("1.20.1-forge"));
assert_eq!(instance.install_stage, InstanceInstallStage::Installed);
let stored =
instance_rows::get_instance_by_id(&instance.id, &state.pool)
.await?
.expect("instance row");
let canonical_root = minecraft.path().canonicalize()?;
let canonical_json = json_path.canonicalize()?;
assert_eq!(stored.linked_launcher.as_deref(), Some("generic"));
assert_eq!(
stored.linked_launcher_root.as_deref(),
Some(canonical_root.to_string_lossy().as_ref())
);
assert_eq!(
stored.linked_dot_minecraft.as_deref(),
Some(canonical_root.to_string_lossy().as_ref())
);
assert_eq!(
stored.linked_version_json_path.as_deref(),
Some(canonical_json.to_string_lossy().as_ref())
);
assert_eq!(stored.linked_version_id.as_deref(), Some("1.20.1-forge"));
let metadata = instance_rows::get_instance_metadata_by_id(
&instance.id,
&state.pool,
)
.await?
.expect("metadata row");
assert_eq!(metadata.applied_content_set.loader, ModLoader::Forge);
assert_eq!(metadata.applied_content_set.game_version, "1.20.1");
// Directly associated instances intentionally project no loader
// version: the loader is installed and managed by the external
// launcher, so the parsed value is not shown.
assert_eq!(metadata.applied_content_set.loader_version, None);
assert!(
!state
.directories
.instances_dir()
.join(&instance.path)
.exists()
);
assert_eq!(std::fs::read(json_path)?, source_before);
Ok(())
}
#[tokio::test]
async fn compatible_mode_fields_round_trip() -> crate::Result<()> {
let (_temp, state) = test_state_with_pool().await?;
let minecraft = TempDir::new()?;
let json_path = write_version(
minecraft.path(),
"1.20.4",
"1.20.4",
json!({
"id": "1.20.4",
"mainClass": "net.minecraft.client.main.Main",
"type": "release"
}),
)?;
let instance = create_direct_link_instance(
CreateDirectLinkInstance {
name: None,
launcher_type: ImportLauncherType::PCL2CE,
base_path: minecraft.path().to_path_buf(),
instance_folder: "Friendly Name".to_string(),
instance_path: Some(
json_path
.parent()
.expect("version dir")
.to_string_lossy()
.to_string(),
),
game_dir_mode: None,
},
&state,
)
.await?;
assert_eq!(instance.name, "1.20.4");
assert_eq!(instance.linked_launcher.as_deref(), Some("pcl2_ce"));
assert_eq!(instance.linked_version_id.as_deref(), Some("1.20.4"));
Ok(())
}
}
@@ -0,0 +1,354 @@
use crate::launcher::get_loader_version_from_profile;
use crate::state::instances::{
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
InstanceLaunchOverrides, InstanceLink, LoaderComponent,
adapters::sqlite::{
config_sync_rows, content_rows, instance_rows, loader_component_rows,
},
config_sync,
};
use crate::state::{
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
State,
};
use crate::util::fetch::{self, write_cached_icon};
use crate::util::io;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{info, trace};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateInstance {
pub name: String,
pub path: Option<String>,
pub game_version: String,
pub loader: ModLoader,
pub loader_version: Option<String>,
pub icon_path: Option<String>,
pub link: InstanceLink,
#[serde(default)]
pub symlink_target: Option<String>,
#[serde(default)]
pub game_dir_override: Option<String>,
}
pub(crate) async fn create_instance(
input: CreateInstance,
state: &State,
) -> crate::Result<Instance> {
trace!("Creating new instance. {}", input.name);
let (path, full_path) =
resolve_instance_path(&input.name, input.path.as_deref(), state)
.await?;
if let Some(symlink_target) = &input.symlink_target {
io::create_symlink(symlink_target, &full_path).await?;
} else {
io::create_dir_all(&full_path).await?;
}
let result = async {
if let Some(game_dir_override) = input.game_dir_override.as_deref() {
// External game directories are created before the launcher
// resolves them with `canonicalize` during installation.
io::create_dir_all(&PathBuf::from(game_dir_override)).await?;
if is_version_isolated_game_dir(Path::new(game_dir_override)) {
// Keep the generic direct-link resolver on the isolated
// version directory even while it is still empty.
io::create_dir_all(
PathBuf::from(game_dir_override).join("mods"),
)
.await?;
}
}
info!(
"Creating instance at path {}",
&io::canonicalize(&full_path)?.display()
);
let loader_version = if input.loader != ModLoader::Vanilla {
get_loader_version_from_profile(
&input.game_version,
input.loader,
input.loader_version.as_deref(),
)
.await?
.map(|value| value.id)
} else {
None
};
let icon_path =
resolve_icon_path(input.icon_path.as_deref(), state).await?;
let now = Utc::now();
let instance_id = format!("local:{}", Uuid::new_v4());
let content_set_id = format!("content-set:{}", Uuid::new_v4());
let instance = Instance {
id: instance_id.clone(),
path: path.clone(),
applied_content_set_id: Some(content_set_id.clone()),
install_stage: InstanceInstallStage::NotInstalled,
launcher_feature_version: LauncherFeatureVersion::MOST_RECENT,
update_channel: ReleaseChannel::Release,
name: input.name,
icon_path,
symlink_target: input.symlink_target,
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: input.game_dir_override,
created: now,
modified: now,
last_played: None,
pinned_at: None,
submitted_time_played: 0,
recent_time_played: 0,
};
let content_set = ContentSet {
id: content_set_id,
instance_id: instance_id.clone(),
name: "Default".to_string(),
source_kind: content_source_kind(&input.link),
status: ContentSetStatus::Available,
game_version: input.game_version,
protocol_version: None,
loader: input.loader,
loader_version,
revision: 0,
created: now,
modified: now,
};
let launch_overrides =
InstanceLaunchOverrides::empty(instance_id.clone());
let loader_components = LoaderComponent::from_legacy_projection(
instance_id.clone(),
input.loader,
content_set.loader_version.clone(),
);
let mut tx = state.pool.begin().await?;
instance_rows::insert_instance(&instance, &mut tx).await?;
content_rows::insert_content_set(&content_set, &mut tx).await?;
loader_component_rows::replace_loader_components(
&instance_id,
&loader_components,
&mut tx,
)
.await?;
instance_rows::upsert_instance_link(&instance_id, &input.link, &mut tx)
.await?;
instance_rows::replace_instance_groups(&instance_id, &[], &mut tx)
.await?;
instance_rows::upsert_instance_launch_overrides(
&launch_overrides,
&mut tx,
)
.await?;
config_sync_rows::upsert_config_updated_at(&instance_id, &mut *tx)
.await?;
tx.commit().await?;
config_sync::mark_dirty(&instance_id);
crate::state::instances::watcher::watch_instance_folder(
&instance.id,
&instance.path,
&state.directories.instance_game_dir(&instance),
&state.file_watcher,
)
.await;
Ok(instance)
}
.await;
if result.is_err() {
let _ = io::remove_dir_all(&full_path).await;
}
result
}
pub(crate) async fn resolve_instance_path(
name: &str,
path: Option<&str>,
state: &State,
) -> crate::Result<(String, std::path::PathBuf)> {
let base_path = path
.map(ToOwned::to_owned)
.unwrap_or_else(|| sanitize_instance_name(name));
let mut path = base_path.clone();
let mut full_path = state.directories.instances_dir().join(&path);
if path_available(&path, &full_path, state).await? {
return Ok((path, full_path));
}
let mut which = 1;
loop {
path = format!("{base_path} ({which})");
full_path = state.directories.instances_dir().join(&path);
if path_available(&path, &full_path, state).await? {
return Ok((path, full_path));
}
which += 1;
}
}
fn is_version_isolated_game_dir(path: &Path) -> bool {
path.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
}
async fn path_available(
path: &str,
full_path: &std::path::Path,
state: &State,
) -> crate::Result<bool> {
if full_path.exists() {
return Ok(false);
}
Ok(instance_rows::get_instance_by_path(path, &state.pool)
.await?
.is_none())
}
async fn resolve_icon_path(
icon_path: Option<&str>,
state: &State,
) -> crate::Result<Option<String>> {
let Some(icon) = icon_path else {
return Ok(None);
};
let (bytes, file_name) = if icon.starts_with("https://")
|| icon.starts_with("http://")
{
// Icon downloads are best-effort. CurseForge CDN icons often fail
// through local system proxies; never block instance creation on that.
let fetched = match fetch_icon_bytes(icon, state).await {
Ok(bytes) => bytes,
Err(err) => {
tracing::warn!(
"Failed to download instance icon from {icon}: {err}"
);
return Ok(None);
}
};
let name = icon.rsplit('/').next().unwrap_or("icon").to_string();
(fetched, name)
} else {
let data =
match io::read(state.directories.caches_dir().join(icon)).await {
Ok(data) => data,
Err(err) => {
tracing::warn!(
"Failed to read instance icon from {icon}: {err}"
);
return Ok(None);
}
};
(bytes::Bytes::from(data), icon.to_string())
};
let file = write_cached_icon(
&file_name,
&state.directories.caches_dir(),
bytes,
&state.io_semaphore,
)
.await?;
Ok(Some(file.to_string_lossy().to_string()))
}
async fn fetch_icon_bytes(
icon_url: &str,
state: &State,
) -> crate::Result<bytes::Bytes> {
// Prefer a direct connection for CDN hosts that are commonly blocked or
// broken through local HTTP proxies (e.g. media.forgecdn.net).
if is_direct_cdn_icon_url(icon_url) {
let permit = state.fetch_semaphore.0.acquire().await?;
let response = DIRECT_ICON_CLIENT.get(icon_url).send().await?;
drop(permit);
if !response.status().is_success() {
return Err(crate::ErrorKind::OtherError(format!(
"Icon download failed with HTTP {}",
response.status().as_u16()
))
.into());
}
return Ok(response.bytes().await?);
}
fetch::fetch(
icon_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await
}
fn is_direct_cdn_icon_url(url: &str) -> bool {
let Ok(parsed) = reqwest::Url::parse(url) else {
return false;
};
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
host == "forgecdn.net"
|| host.ends_with(".forgecdn.net")
|| host == "media.forgecdn.net"
}
static DIRECT_ICON_CLIENT: std::sync::LazyLock<reqwest::Client> =
std::sync::LazyLock::new(|| {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(15))
.read_timeout(std::time::Duration::from_secs(30))
.user_agent(crate::launcher_user_agent())
.no_proxy()
.build()
.expect("Direct icon client configuration should be valid")
});
fn content_source_kind(link: &InstanceLink) -> ContentSourceKind {
match link {
InstanceLink::Unmanaged => ContentSourceKind::Local,
InstanceLink::ModrinthModpack { .. } => {
ContentSourceKind::ModrinthModpack
}
InstanceLink::CurseForgeModpack { .. } => ContentSourceKind::CurseForge,
InstanceLink::ServerProject { .. }
| InstanceLink::ServerProjectModpack { .. } => {
ContentSourceKind::ServerProject
}
InstanceLink::ImportedModpack { .. } => {
ContentSourceKind::ImportedModpack
}
InstanceLink::SharedInstance { .. } => {
ContentSourceKind::SharedInstance
}
}
}
fn sanitize_instance_name(input: &str) -> String {
input.trim().replace(
['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>', '!'],
"_",
)
}
@@ -0,0 +1,380 @@
use crate::state::instances::{
ContentSourceKind, Instance, InstanceLaunchOverrides, InstanceLink,
LoaderComponent,
adapters::sqlite::{
config_sync_rows, content_rows, instance_rows, loader_component_rows,
},
config_sync,
};
use crate::state::{
Hooks, InstanceInstallStage, LauncherFeatureVersion, MemorySettings,
ModLoader, ReleaseChannel, WindowSize,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct EditInstance {
pub install_stage: Option<InstanceInstallStage>,
pub launcher_feature_version: Option<LauncherFeatureVersion>,
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 update_channel: Option<ReleaseChannel>,
pub groups: Option<Vec<String>>,
pub link: Option<InstanceLink>,
pub launch_overrides: Option<InstanceLaunchOverridesPatch>,
pub content_set_patch: Option<AppliedContentSetPatch>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub last_played: Option<Option<DateTime<Utc>>>,
pub submitted_time_played: Option<u64>,
pub recent_time_played: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub symlink_target: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub game_dir_override: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct InstanceLaunchOverridesPatch {
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub java_path: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub extra_launch_args: Option<Option<Vec<String>>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub custom_env_vars: Option<Option<Vec<(String, String)>>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub memory: Option<Option<MemorySettings>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub force_fullscreen: Option<Option<bool>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub maximize_window: Option<Option<bool>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub game_resolution: Option<Option<WindowSize>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub launch_preparation_timeout: Option<Option<u64>>,
pub hooks: Option<Hooks>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct AppliedContentSetPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_kind: Option<ContentSourceKind>,
pub game_version: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub protocol_version: Option<Option<u32>>,
pub loader: Option<ModLoader>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub loader_version: Option<Option<String>>,
}
pub(crate) async fn edit_instance(
instance_id: &str,
patch: EditInstance,
pool: &SqlitePool,
) -> crate::Result<Instance> {
let mut instance = instance_rows::get_instance_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let now = Utc::now();
apply_instance_patch(&mut instance, &patch, now);
let loader_projection_changed =
patch.content_set_patch.as_ref().is_some_and(|patch| {
patch.loader.is_some() || patch.loader_version.is_some()
});
let mut content_set = match patch.content_set_patch {
Some(content_set_patch) => {
let applied_content_set =
content_rows::get_applied_content_set(&instance.id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Instance {} has no applied content set",
instance.id
))
})?;
Some(apply_content_set_patch(
applied_content_set,
content_set_patch,
now,
))
}
None => None,
};
let mut launch_overrides = match patch.launch_overrides {
Some(launch_patch) => {
let current = instance_rows::get_instance_launch_overrides(
&instance.id,
pool,
)
.await?
.unwrap_or_else(|| {
InstanceLaunchOverrides::empty(instance.id.clone())
});
Some(apply_launch_overrides_patch(current, launch_patch))
}
None => None,
};
let mut tx = pool.begin().await?;
instance_rows::update_instance(&instance, &mut tx).await?;
if let Some(content_set) = content_set.as_mut() {
content_rows::update_content_set(content_set, &mut tx).await?;
if loader_projection_changed {
let components = LoaderComponent::from_legacy_projection(
instance.id.clone(),
content_set.loader,
content_set.loader_version.clone(),
);
loader_component_rows::replace_loader_components(
&instance.id,
&components,
&mut tx,
)
.await?;
}
}
if let Some(link) = &patch.link {
instance_rows::upsert_instance_link(&instance.id, link, &mut tx)
.await?;
if matches!(link, InstanceLink::Unmanaged) {
let content_set_id = content_set
.as_ref()
.map(|content_set| content_set.id.as_str())
.or(instance.applied_content_set_id.as_deref());
if let Some(content_set_id) = content_set_id {
sqlx::query(
"UPDATE instance_content_sets SET source_kind = 'local', modified = ? WHERE id = ?",
)
.bind(now.timestamp())
.bind(content_set_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE instance_content_entries SET source_kind = 'local', ownership_kind = 'user_added', modified_at = ? WHERE content_set_id = ? AND ownership_kind = 'pack_managed'",
)
.bind(now.timestamp())
.bind(content_set_id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM instance_pack_members WHERE content_set_id = ?")
.bind(content_set_id)
.execute(&mut *tx)
.await?;
}
}
}
if let Some(groups) = &patch.groups {
instance_rows::replace_instance_groups(&instance.id, groups, &mut tx)
.await?;
}
if let Some(overrides) = launch_overrides.as_mut() {
instance_rows::upsert_instance_launch_overrides(overrides, &mut tx)
.await?;
}
config_sync_rows::upsert_config_updated_at(&instance.id, &mut *tx).await?;
tx.commit().await?;
config_sync::mark_dirty(&instance.id);
Ok(instance)
}
pub(crate) async fn restore_instance_metadata(
metadata: &crate::state::InstanceMetadata,
pool: &SqlitePool,
) -> crate::Result<()> {
let mut tx = pool.begin().await?;
let instance = metadata.instance.clone();
let mut content_set = metadata.applied_content_set.clone();
let mut launch_overrides = metadata.launch_overrides.clone();
instance_rows::update_instance(&instance, &mut tx).await?;
content_rows::update_content_set(&mut content_set, &mut tx).await?;
loader_component_rows::replace_loader_components(
&instance.id,
&metadata.loader_components,
&mut tx,
)
.await?;
instance_rows::upsert_instance_link(&instance.id, &metadata.link, &mut tx)
.await?;
instance_rows::replace_instance_groups(
&instance.id,
&metadata.groups,
&mut tx,
)
.await?;
instance_rows::upsert_instance_launch_overrides(
&mut launch_overrides,
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
fn apply_instance_patch(
instance: &mut Instance,
patch: &EditInstance,
now: DateTime<Utc>,
) {
if let Some(install_stage) = patch.install_stage {
instance.install_stage = install_stage;
}
if let Some(launcher_feature_version) = patch.launcher_feature_version {
instance.launcher_feature_version = launcher_feature_version;
}
if let Some(name) = &patch.name {
instance.name = name.clone();
}
if let Some(icon_path) = &patch.icon_path {
instance.icon_path = icon_path.clone();
}
if let Some(update_channel) = patch.update_channel {
instance.update_channel = update_channel;
}
if let Some(last_played) = &patch.last_played {
instance.last_played = *last_played;
}
if let Some(submitted_time_played) = patch.submitted_time_played {
instance.submitted_time_played = submitted_time_played;
}
if let Some(recent_time_played) = patch.recent_time_played {
instance.recent_time_played = recent_time_played;
}
if let Some(symlink_target) = &patch.symlink_target {
instance.symlink_target = symlink_target.clone();
}
if let Some(game_dir_override) = &patch.game_dir_override {
instance.game_dir_override = game_dir_override.clone();
}
instance.modified = now;
}
fn apply_content_set_patch(
mut content_set: crate::state::instances::ContentSet,
patch: AppliedContentSetPatch,
now: DateTime<Utc>,
) -> crate::state::instances::ContentSet {
if let Some(game_version) = patch.game_version {
content_set.game_version = game_version;
}
if let Some(source_kind) = patch.source_kind {
content_set.source_kind = source_kind;
}
if let Some(protocol_version) = patch.protocol_version {
content_set.protocol_version = protocol_version;
}
if let Some(loader) = patch.loader {
content_set.loader = loader;
}
if let Some(loader_version) = patch.loader_version {
content_set.loader_version = loader_version;
}
content_set.modified = now;
content_set
}
fn apply_launch_overrides_patch(
mut overrides: InstanceLaunchOverrides,
patch: InstanceLaunchOverridesPatch,
) -> InstanceLaunchOverrides {
if let Some(java_path) = patch.java_path {
overrides.java_path = java_path;
}
if let Some(extra_launch_args) = patch.extra_launch_args {
overrides.extra_launch_args = extra_launch_args;
}
if let Some(custom_env_vars) = patch.custom_env_vars {
overrides.custom_env_vars = custom_env_vars;
}
if let Some(memory) = patch.memory {
overrides.memory = memory;
}
if let Some(force_fullscreen) = patch.force_fullscreen {
overrides.force_fullscreen = force_fullscreen;
}
if let Some(maximize_window) = patch.maximize_window {
overrides.maximize_window = maximize_window;
}
if let Some(game_resolution) = patch.game_resolution {
overrides.game_resolution = game_resolution;
}
if let Some(timeout) = patch.launch_preparation_timeout {
overrides.launch_preparation_timeout = timeout;
}
if let Some(hooks) = patch.hooks {
overrides.hooks = hooks;
}
overrides
}
@@ -0,0 +1,93 @@
use crate::state::instances::{
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
LoaderComponent,
adapters::sqlite::{instance_rows, loader_component_rows},
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InstanceMetadata {
pub instance: Instance,
pub applied_content_set: ContentSet,
pub link: InstanceLink,
pub groups: Vec<String>,
pub launch_overrides: InstanceLaunchOverrides,
#[serde(default)]
pub loader_components: Vec<LoaderComponent>,
}
pub(crate) async fn get_instance(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceMetadata>> {
get_instance_metadata(instance_id, pool).await
}
pub(crate) async fn get_instance_metadata(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceMetadata>> {
let Some(record) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(None);
};
let loader_components =
loader_component_rows::list_loader_components(instance_id, pool)
.await?;
Ok(Some(InstanceMetadata::from_record(
record,
loader_components,
)))
}
pub(crate) async fn get_instances_metadata(
instance_ids: &[&str],
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceMetadata>> {
let records =
instance_rows::get_instance_metadata_many(instance_ids, pool).await?;
let mut metadata = Vec::with_capacity(records.len());
for record in records {
let components = loader_component_rows::list_loader_components(
&record.instance.id,
pool,
)
.await?;
metadata.push(InstanceMetadata::from_record(record, components));
}
Ok(metadata)
}
pub(crate) async fn list_instances(
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceMetadata>> {
let records = instance_rows::list_instance_metadata(pool).await?;
let mut metadata = Vec::with_capacity(records.len());
for record in records {
let components = loader_component_rows::list_loader_components(
&record.instance.id,
pool,
)
.await?;
metadata.push(InstanceMetadata::from_record(record, components));
}
Ok(metadata)
}
impl InstanceMetadata {
fn from_record(
record: instance_rows::InstanceMetadataRecord,
loader_components: Vec<LoaderComponent>,
) -> Self {
Self {
instance: record.instance,
applied_content_set: record.applied_content_set,
link: record.link,
groups: record.groups,
launch_overrides: record.launch_overrides,
loader_components,
}
}
}
@@ -0,0 +1,621 @@
use crate::state::State;
use crate::util::io;
use crate::{ErrorKind, Result};
use std::path::{Path, PathBuf};
/// Import a world save from a source path into an instance's saves directory.
///
/// The `source_path` can be either:
/// - A directory containing a `level.dat` file (an existing world folder)
/// - A ZIP archive containing a world save (`level.dat` at the archive root,
/// or inside a single shared root folder such as `My World/level.dat`)
///
/// Returns the name of the imported world.
pub async fn import_world_save(
_state: &State,
instance_id: &str,
source_path: &Path,
inner_base: Option<&str>,
) -> Result<String> {
let instance_id_str = instance_id.to_string();
let resolved_source = if source_path.is_dir() {
match inner_base {
Some(base) => io::join_within_root(source_path, base)?,
None => source_path.to_path_buf(),
}
} else {
source_path.to_path_buf()
};
// Resolve the instance's saves directory.
let instance_path =
crate::api::instance::get_full_path(&instance_id_str).await?;
let saves_dir = instance_path.join("saves");
// Determine the world folder name and source type.
let (world_name, source_is_zip) = if resolved_source.is_dir() {
// Direct folder: use the folder name as the world name.
let name = resolved_source
.file_name()
.ok_or_else(|| {
ErrorKind::InputError(
"Cannot determine world name from source path".to_string(),
)
})?
.to_string_lossy()
.to_string();
(name, false)
} else if resolved_source.is_file() {
// Check if it's a ZIP archive by examining the file signature.
let is_zip = is_zip_file(&resolved_source).await?;
if is_zip {
// ZIP file: use the file stem as the world name.
let name = resolved_source
.file_stem()
.ok_or_else(|| {
ErrorKind::InputError(
"Cannot determine world name from ZIP file name"
.to_string(),
)
})?
.to_string_lossy()
.to_string();
(name, true)
} else {
return Err(ErrorKind::InputError(
"Source file is not a valid ZIP archive or world folder"
.to_string(),
)
.into());
}
} else {
return Err(ErrorKind::InputError(format!(
"Source path does not exist: {}",
source_path.display()
))
.into());
};
// Check if the world already exists in the saves directory.
let target_dir = saves_dir.join(&world_name);
if target_dir.exists() {
return Err(ErrorKind::InputError(format!(
"World '{world_name}' already exists in this instance"
))
.into());
}
// Create the saves directory if it doesn't exist.
io::create_dir_all(&saves_dir).await?;
let import_result = async {
if source_is_zip {
// Extract ZIP archive to the target directory.
extract_world_zip(&resolved_source, &target_dir).await?;
// Deep-nesting fallback: the archive may wrap the world in backup
// folders or even inside another ZIP. Hoist the first `level.dat`'s
// folder to the target root, extracting nested archives as needed.
let target_for_locator = target_dir.clone();
tokio::task::spawn_blocking(move || {
locate_world_root_sync(&target_for_locator, 0)
})
.await
.map_err(|e| {
ErrorKind::InputError(format!(
"World location task panicked: {e}"
))
})?
.map_err(|e| {
ErrorKind::InputError(format!(
"Failed to locate world inside archive: {e}"
))
})?;
} else {
// Copy the folder recursively.
io::copy_dir(&resolved_source, &target_dir).await?;
}
Ok::<(), crate::Error>(())
}
.await;
if let Err(error) = import_result {
let _ = tokio::fs::remove_dir_all(&target_dir).await;
return Err(error);
}
// Verify that the extracted/copied world has a level.dat file.
if !target_dir.join("level.dat").exists() {
// Clean up on failure.
let _ = tokio::fs::remove_dir_all(&target_dir).await;
return Err(ErrorKind::InputError(format!(
"No level.dat found in the imported world save '{world_name}'"
))
.into());
}
// Emit an instance synced event so the UI refreshes the worlds list.
crate::event::emit::emit_instance(
&instance_id_str,
crate::event::InstancePayloadType::Synced,
)
.await?;
crate::event::emit::emit_instance(
&instance_id_str,
crate::event::InstancePayloadType::WorldUpdated {
world: world_name.clone(),
},
)
.await?;
tracing::info!(
"Imported world save '{world_name}' into instance {instance_id_str}"
);
Ok(world_name)
}
/// Check if a file is a ZIP archive by reading its magic bytes.
async fn is_zip_file(path: &Path) -> Result<bool> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path)
.await
.map_err(|e| io::IOError::with_path(e, path))?;
let mut magic = [0u8; 4];
match file.read_exact(&mut magic).await {
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
// File too small to be a ZIP
return Ok(false);
}
Err(e) => {
return Err(io::IOError::with_path(e, path).into());
}
_ => {}
}
// ZIP magic bytes: PK\x03\x04
Ok(magic == [0x50, 0x4B, 0x03, 0x04])
}
/// Extract a ZIP archive containing a world save to the target directory.
///
/// Supports flat archives (`level.dat` at the root) and archives whose
/// entries all share a single root folder (`My World/level.dat`); the shared
/// root is stripped only when every entry lives inside it. Entry names are
/// normalized (backslashes become `/`) and validated so `..`, absolute paths
/// and drive letters can never write outside `target_dir`.
async fn extract_world_zip(zip_path: &Path, target_dir: &Path) -> Result<()> {
let zip_path = zip_path.to_path_buf();
let target_dir = target_dir.to_path_buf();
tokio::task::spawn_blocking(move || {
extract_world_zip_sync(&zip_path, &target_dir)
})
.await?
}
fn extract_world_zip_sync(zip_path: &Path, target_dir: &Path) -> Result<()> {
let file = std::fs::File::open(zip_path)
.map_err(|e| io::IOError::with_path(e, zip_path))?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| {
ErrorKind::InputError(format!("Invalid ZIP archive: {e}"))
})?;
// First pass: sanitize every entry name before writing anything. A world
// archive with an escaping entry is rejected as a whole.
let mut entries: Vec<(usize, PathBuf, bool)> = Vec::new();
for i in 0..archive.len() {
let entry = archive.by_index(i).map_err(|e| {
ErrorKind::InputError(format!("Failed to read ZIP entry: {e}"))
})?;
let raw_name = entry.name().to_string();
if raw_name.starts_with("__MACOSX") {
continue;
}
let is_dir =
entry.is_dir() || raw_name.replace('\\', "/").ends_with('/');
let safe_name = sanitize_entry_name(&raw_name).ok_or_else(|| {
ErrorKind::InputError(format!(
"World archive contains an unsafe ZIP entry: {raw_name}"
))
})?;
if safe_name.as_os_str().is_empty() {
continue;
}
entries.push((i, safe_name, is_dir));
}
// Strip a shared root folder only when every file entry is nested below
// it. Root-level directory entries don't block stripping, and a flat
// archive (level.dat at the root) keeps its paths untouched.
let all_nested = entries
.iter()
.filter(|(_, _, is_dir)| !is_dir)
.all(|(_, path, _)| path.components().count() > 1);
let root = common_root(&entries);
let strip_root = all_nested && root.is_some();
for (index, safe_name, is_dir) in &entries {
let relative = if strip_root {
let mut components = safe_name.components();
components.next();
components.as_path().to_path_buf()
} else {
safe_name.clone()
};
let output_path = target_dir.join(relative);
if *is_dir {
std::fs::create_dir_all(&output_path)
.map_err(|e| io::IOError::with_path(e, &output_path))?;
continue;
}
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| io::IOError::with_path(e, parent))?;
}
let mut entry = archive.by_index(*index).map_err(|e| {
ErrorKind::InputError(format!("Failed to read ZIP entry: {e}"))
})?;
let mut output = std::fs::File::create(&output_path)
.map_err(|e| io::IOError::with_path(e, &output_path))?;
std::io::copy(&mut entry, &mut output)
.map_err(|e| io::IOError::with_path(e, &output_path))?;
}
Ok(())
}
/// Returns the first path component shared by every entry, if any.
fn common_root(entries: &[(usize, PathBuf, bool)]) -> Option<&std::ffi::OsStr> {
let mut root: Option<&std::ffi::OsStr> = None;
for (_, path, _) in entries {
let first = path.components().next()?.as_os_str();
match root {
None => root = Some(first),
Some(existing) if existing != first => return None,
_ => {}
}
}
root
}
/// Normalize a ZIP entry name into a safe relative path that stays inside the
/// extraction directory. Returns `None` for absolute paths, drive letters or
/// entries containing `..` (zip-slip protection).
fn sanitize_entry_name(name: &str) -> Option<PathBuf> {
// The ZIP spec mandates `/` separators, but tolerate backslashes from
// Windows-authored archives.
let normalized = name.replace('\\', "/");
let path = Path::new(&normalized);
if path.is_absolute() {
return None;
}
let mut safe = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Normal(part) => safe.push(part),
std::path::Component::CurDir => {}
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => return None,
}
}
Some(safe)
}
/// Maximum number of nested archives unwrapped while locating a world.
const MAX_WORLD_ZIP_NESTING_DEPTH: u32 = 5;
/// After extracting a world ZIP, restructure `target_dir` so `level.dat`
/// ends up directly under it: search the extracted tree for the first
/// `level.dat` and hoist its folder to the root; if only nested archives
/// are present, extract the first one and repeat. Flat and single-root
/// archives are already correct and return immediately.
fn locate_world_root_sync(
target_dir: &Path,
depth: u32,
) -> std::io::Result<()> {
if target_dir.join("level.dat").is_file() {
return Ok(());
}
if depth >= MAX_WORLD_ZIP_NESTING_DEPTH {
return Ok(());
}
if let Some(world_folder) = find_level_dat_folder(target_dir) {
hoist_contents_sync(&world_folder, target_dir)?;
return Ok(());
}
if let Some(nested_zip) = find_nested_archive(target_dir) {
extract_world_zip_sync(&nested_zip, target_dir).map_err(|e| {
std::io::Error::other(format!(
"Failed to extract nested archive '{}': {e}",
nested_zip.display()
))
})?;
// The extracted archive itself is not part of the world.
let _ = std::fs::remove_file(&nested_zip);
return locate_world_root_sync(target_dir, depth + 1);
}
Ok(())
}
/// Depth-first search for a directory containing `level.dat`, sorted by
/// entry name for deterministic results.
fn find_level_dat_folder(dir: &Path) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(dir)
.ok()?
.collect::<std::io::Result<Vec<_>>>()
.ok()?;
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
if path.is_dir() {
if path.join("level.dat").is_file() {
return Some(path);
}
if let Some(found) = find_level_dat_folder(&path) {
return Some(found);
}
}
}
None
}
/// Depth-first search for the first `.zip` / `.mrpack` file, sorted by entry
/// name for deterministic results.
fn find_nested_archive(dir: &Path) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(dir)
.ok()?
.collect::<std::io::Result<Vec<_>>>()
.ok()?;
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_nested_archive(&path) {
return Some(found);
}
} else if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
e.eq_ignore_ascii_case("zip") || e.eq_ignore_ascii_case("mrpack")
}) {
return Some(path);
}
}
None
}
/// Move every child of `world_folder` into `target_dir`, then remove the
/// now-empty wrapper folder.
fn hoist_contents_sync(
world_folder: &Path,
target_dir: &Path,
) -> std::io::Result<()> {
let entries = std::fs::read_dir(world_folder)?;
for entry in entries {
let entry = entry?;
let dest = target_dir.join(entry.file_name());
if dest.exists() {
return Err(std::io::Error::other(format!(
"Cannot hoist '{}': '{}' already exists",
entry.path().display(),
dest.display()
)));
}
std::fs::rename(entry.path(), &dest)?;
}
let _ = std::fs::remove_dir(world_folder);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
use tempfile::tempdir;
fn write_zip(entries: &[(&str, &[u8])], zip_path: &Path) {
let file = std::fs::File::create(zip_path).expect("create zip");
let mut zip = zip::ZipWriter::new(file);
for (name, bytes) in entries {
zip.start_file(name, zip::write::FileOptions::<()>::default())
.expect("start entry");
zip.write_all(bytes).expect("write entry");
}
zip.finish().expect("finish zip");
}
fn extract_to_temp(
entries: &[(&str, &[u8])],
) -> (tempfile::TempDir, tempfile::TempDir) {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("world.zip");
write_zip(entries, &zip_path);
let out_dir = tempdir().expect("temp out dir");
extract_world_zip_sync(&zip_path, out_dir.path()).expect("extract");
(dir, out_dir)
}
#[test]
fn flat_zip_keeps_root_files() {
let (_, out_dir) = extract_to_temp(&[
("level.dat", b"flat"),
("region/r.0.0.mca", b"mca"),
]);
assert!(out_dir.path().join("level.dat").exists());
assert!(out_dir.path().join("region/r.0.0.mca").exists());
}
#[test]
fn single_root_zip_is_stripped() {
let (_, out_dir) = extract_to_temp(&[
("My World/level.dat", b"rooted"),
("My World/region/r.0.0.mca", b"mca"),
]);
assert!(out_dir.path().join("level.dat").exists());
assert!(!out_dir.path().join("My World").exists());
}
#[test]
fn backslash_entries_are_normalized() {
let (_, out_dir) = extract_to_temp(&[
("My World\\level.dat", b"rooted"),
("My World\\region\\r.0.0.mca", b"mca"),
]);
assert!(out_dir.path().join("level.dat").exists());
assert!(!out_dir.path().join("My World").exists());
}
#[test]
fn traversal_entries_are_rejected() {
let dir = tempdir().expect("temp dir");
let zip_path = dir.path().join("world.zip");
write_zip(
&[("level.dat", b"world"), ("../../evil.txt", b"escape")],
&zip_path,
);
let out_dir = tempdir().expect("temp out dir");
assert!(extract_world_zip_sync(&zip_path, out_dir.path()).is_err());
assert!(!dir.path().join("evil.txt").exists());
assert!(!out_dir.path().join("level.dat").exists());
assert_eq!(
std::fs::read_dir(out_dir.path())
.expect("read out dir")
.count(),
0
);
}
#[test]
fn directory_entries_are_created() {
let (_, out_dir) = extract_to_temp(&[
("My World/", b""),
("My World/level.dat", b"rooted"),
]);
assert!(out_dir.path().join("level.dat").exists());
assert!(!out_dir.path().join("My World").exists());
}
fn extract_and_locate(entries: &[(&str, &[u8])]) -> tempfile::TempDir {
let (_, out_dir) = extract_to_temp(entries);
locate_world_root_sync(out_dir.path(), 0).expect("locate world");
out_dir
}
#[test]
fn nested_backup_folders_are_hoisted() {
let out_dir = extract_and_locate(&[
("Backup/Worlds/My World/level.dat", b"rooted"),
("Backup/Worlds/My World/region/r.0.0.mca", b"mca"),
]);
assert!(
out_dir.path().join("level.dat").exists(),
"world nested under backup folders should be hoisted to the root"
);
assert!(out_dir.path().join("region/r.0.0.mca").exists());
assert!(!out_dir.path().join("Backup").exists());
}
#[test]
fn nested_world_zip_is_extracted() {
let mut inner = Vec::new();
{
let mut writer =
zip::ZipWriter::new(std::io::Cursor::new(&mut inner));
writer
.start_file(
"My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start inner entry");
writer.write_all(b"rooted").expect("write inner");
writer
.start_file(
"My World/region/r.0.0.mca",
zip::write::FileOptions::<()>::default(),
)
.expect("start inner region");
writer.write_all(b"mca").expect("write inner region");
writer.finish().expect("finish inner");
}
let (_, out_dir) = extract_to_temp(&[
("Backup/worlds/world1.zip", &inner),
("Backup/worlds/world2.zip", &inner),
]);
locate_world_root_sync(out_dir.path(), 0).expect("locate world");
assert!(
out_dir.path().join("level.dat").exists(),
"nested world zip should be extracted and hoisted"
);
assert!(out_dir.path().join("region/r.0.0.mca").exists());
assert!(
!out_dir.path().join("Backup/worlds/world1.zip").exists(),
"extracted nested archive should be removed"
);
}
#[test]
fn nested_zip_chain_within_limit() {
let mut world = Vec::new();
{
let mut writer =
zip::ZipWriter::new(std::io::Cursor::new(&mut world));
writer
.start_file(
"My World/level.dat",
zip::write::FileOptions::<()>::default(),
)
.expect("start world entry");
writer.write_all(b"rooted").expect("write world");
writer.finish().expect("finish world");
}
let mut c = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut c));
writer
.start_file(
"world.zip",
zip::write::FileOptions::<()>::default(),
)
.expect("start c entry");
writer.write_all(&world).expect("write c");
writer.finish().expect("finish c");
}
let mut b = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut b));
writer
.start_file("c.zip", zip::write::FileOptions::<()>::default())
.expect("start b entry");
writer.write_all(&c).expect("write b");
writer.finish().expect("finish b");
}
let mut a = Vec::new();
{
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut a));
writer
.start_file("b.zip", zip::write::FileOptions::<()>::default())
.expect("start a entry");
writer.write_all(&b).expect("write a");
writer.finish().expect("finish a");
}
let (_, out_dir) = extract_to_temp(&[("Backup/a.zip", &a)]);
locate_world_root_sync(out_dir.path(), 0).expect("locate world");
assert!(
out_dir.path().join("level.dat").exists(),
"nested zip chain should unwrap to the world"
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,720 @@
use crate::state::InstanceInstallStage;
use crate::state::instances::{
InstanceLaunchContext, LoaderComponent,
adapters::sqlite::{
config_sync_rows, instance_rows, loader_component_rows,
},
config_sync, playtime_to_storage,
};
use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc};
use sqlx::SqlitePool;
use crate::state::instances::{DailyPlaytime, DailyPlaytimeEntry};
pub(crate) async fn get_instance_launch_context(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceLaunchContext>> {
instance_rows::get_instance_launch_context(instance_id, pool).await
}
pub(crate) async fn replace_instance_loader_components(
instance_id: &str,
components: &[LoaderComponent],
pool: &SqlitePool,
) -> crate::Result<()> {
let (loader, loader_version) =
crate::state::project_loader_components(components)?;
let mut tx = pool.begin().await?;
loader_component_rows::replace_loader_components(
instance_id,
components,
&mut tx,
)
.await?;
sqlx::query(
"UPDATE instance_content_sets
SET loader = ?, loader_version = ?, modified = ?
WHERE id = (
SELECT applied_content_set_id FROM instances WHERE id = ?
)",
)
.bind(loader.as_str())
.bind(loader_version)
.bind(Utc::now().timestamp())
.bind(instance_id)
.execute(&mut *tx)
.await?;
config_sync_rows::upsert_config_updated_at(instance_id, &mut *tx).await?;
tx.commit().await?;
config_sync::mark_dirty(instance_id);
Ok(())
}
pub(crate) async fn set_instance_install_stage(
instance_id: &str,
install_stage: InstanceInstallStage,
pool: &SqlitePool,
) -> crate::Result<()> {
let install_stage = install_stage.as_str();
let modified = Utc::now().timestamp();
sqlx::query!(
"
UPDATE instances
SET install_stage = ?, modified = ?
WHERE id = ?
",
install_stage,
modified,
instance_id,
)
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn set_applied_content_set_loader_version(
instance_id: &str,
loader_version: Option<&str>,
pool: &SqlitePool,
) -> crate::Result<()> {
let modified = Utc::now().timestamp();
let mut tx = pool.begin().await?;
sqlx::query!(
"
UPDATE instance_content_sets
SET loader_version = ?, modified = ?
WHERE id = (
SELECT applied_content_set_id
FROM instances
WHERE id = ?
)
",
loader_version,
modified,
instance_id,
)
.execute(&mut *tx)
.await?;
config_sync_rows::upsert_config_updated_at(instance_id, &mut *tx).await?;
tx.commit().await?;
config_sync::mark_dirty(instance_id);
Ok(())
}
pub(crate) async fn set_applied_content_set_protocol_version(
instance_id: &str,
protocol_version: Option<u32>,
pool: &SqlitePool,
) -> crate::Result<()> {
let protocol_version = protocol_version.map(i64::from);
let modified = Utc::now().timestamp();
let mut tx = pool.begin().await?;
sqlx::query!(
"
UPDATE instance_content_sets
SET protocol_version = ?, modified = ?
WHERE id = (
SELECT applied_content_set_id
FROM instances
WHERE id = ?
)
",
protocol_version,
modified,
instance_id,
)
.execute(&mut *tx)
.await?;
config_sync_rows::upsert_config_updated_at(instance_id, &mut *tx).await?;
tx.commit().await?;
config_sync::mark_dirty(instance_id);
Ok(())
}
pub(crate) async fn set_instance_last_played(
instance_id: &str,
last_played: DateTime<Utc>,
pool: &SqlitePool,
) -> crate::Result<()> {
let last_played = last_played.timestamp();
let modified = Utc::now().timestamp();
sqlx::query!(
"
UPDATE instances
SET last_played = ?, modified = ?
WHERE id = ?
",
last_played,
modified,
instance_id,
)
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn set_instance_pinned(
instance_id: &str,
pinned: bool,
pool: &SqlitePool,
) -> crate::Result<()> {
let pinned_at = pinned.then(|| Utc::now().timestamp());
let modified = Utc::now().timestamp();
let result = sqlx::query!(
"
UPDATE instances
SET pinned_at = ?, modified = ?
WHERE id = ?
",
pinned_at,
modified,
instance_id,
)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(
"Unknown instance".to_string(),
)
.into());
}
Ok(())
}
pub(crate) async fn record_instance_play_session(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
let now = Utc::now();
let instance_name = get_instance_name(instance_id, pool).await?;
upsert_daily_playtime(
instance_id,
&instance_name,
now.with_timezone(&Local).date_naive(),
0,
1,
pool,
)
.await
}
pub(crate) async fn record_instance_daily_playtime(
instance_id: &str,
started_at: DateTime<Utc>,
ended_at: DateTime<Utc>,
pool: &SqlitePool,
) -> crate::Result<()> {
if ended_at <= started_at {
return Ok(());
}
let instance_name = get_instance_name(instance_id, pool).await?;
for (played_on, elapsed) in
split_daily_playtime(started_at, ended_at, &Local)?
{
upsert_daily_playtime(
instance_id,
&instance_name,
played_on,
elapsed,
0,
pool,
)
.await?;
}
Ok(())
}
fn split_daily_playtime<Tz: TimeZone>(
started_at: DateTime<Utc>,
ended_at: DateTime<Utc>,
time_zone: &Tz,
) -> crate::Result<Vec<(NaiveDate, u64)>> {
if ended_at <= started_at {
return Ok(Vec::new());
}
let mut segment_start = started_at;
let mut segments = Vec::new();
while segment_start < ended_at {
let local_start = segment_start.with_timezone(time_zone);
let played_on = local_start.date_naive();
let next_day = played_on.succ_opt().ok_or_else(|| {
crate::ErrorKind::InputError(
"Unable to determine the next local day".to_string(),
)
})?;
let next_midnight = next_day.and_hms_opt(0, 0, 0).ok_or_else(|| {
crate::ErrorKind::InputError(
"Unable to determine the next local midnight".to_string(),
)
})?;
let next_boundary = time_zone
.from_local_datetime(&next_midnight)
.earliest()
.or_else(|| time_zone.from_local_datetime(&next_midnight).latest())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Unable to resolve the next local day boundary".to_string(),
)
})?
.with_timezone(&Utc);
let segment_end = next_boundary.min(ended_at);
let elapsed = (segment_end - segment_start).num_seconds();
if elapsed <= 0 {
return Err(crate::ErrorKind::InputError(
"Unable to advance local playtime boundary".to_string(),
)
.into());
}
segments.push((played_on, elapsed as u64));
segment_start = segment_end;
}
Ok(segments)
}
pub(crate) async fn get_daily_playtime(
start_date: NaiveDate,
end_date: NaiveDate,
pool: &SqlitePool,
) -> crate::Result<Vec<DailyPlaytime>> {
if start_date > end_date {
return Err(crate::ErrorKind::InputError(
"Start date must not be after end date".to_string(),
)
.into());
}
let start_date = start_date.format("%Y-%m-%d").to_string();
let end_date = end_date.format("%Y-%m-%d").to_string();
let rows = sqlx::query!(
r#"
SELECT
daily.played_on AS "date!: String",
SUM(daily.played_seconds) AS "played_seconds!: i64",
SUM(daily.session_count) AS "session_count!: i64",
(
SELECT candidate.instance_name
FROM instance_daily_playtime candidate
WHERE candidate.played_on = daily.played_on
AND candidate.played_seconds > 0
ORDER BY candidate.played_seconds DESC, candidate.instance_name ASC
LIMIT 1
) AS "top_instance_name?: String"
FROM instance_daily_playtime daily
WHERE daily.played_on BETWEEN ? AND ?
GROUP BY daily.played_on
ORDER BY daily.played_on
"#,
start_date,
end_date,
)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(DailyPlaytime {
date: row.date,
played_seconds: u64::try_from(row.played_seconds).map_err(
|_| {
crate::ErrorKind::InputError(
"Invalid daily playtime value".to_string(),
)
},
)?,
session_count: u64::try_from(row.session_count).map_err(
|_| {
crate::ErrorKind::InputError(
"Invalid daily session count".to_string(),
)
},
)?,
top_instance_name: row.top_instance_name,
})
})
.collect()
}
pub(crate) async fn get_daily_playtime_details(
date: NaiveDate,
pool: &SqlitePool,
) -> crate::Result<Vec<DailyPlaytimeEntry>> {
let date = date.format("%Y-%m-%d").to_string();
let rows = sqlx::query!(
r#"
SELECT
daily.instance_id AS "instance_id!: String",
daily.instance_name AS "instance_name!: String",
daily.played_seconds AS "played_seconds!: i64",
daily.session_count AS "session_count!: i64"
FROM instance_daily_playtime daily
WHERE daily.played_on = ?
AND daily.played_seconds > 0
ORDER BY daily.played_seconds DESC, daily.instance_name ASC
"#,
date,
)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(DailyPlaytimeEntry {
instance_id: row.instance_id,
instance_name: row.instance_name,
played_seconds: u64::try_from(row.played_seconds).map_err(
|_| {
crate::ErrorKind::InputError(
"Invalid daily playtime value".to_string(),
)
},
)?,
session_count: u64::try_from(row.session_count).map_err(
|_| {
crate::ErrorKind::InputError(
"Invalid daily session count".to_string(),
)
},
)?,
})
})
.collect()
}
async fn get_instance_name(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<String> {
sqlx::query_scalar!(
"
SELECT name
FROM instances
WHERE id = ?
",
instance_id,
)
.fetch_optional(pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string()).into()
})
}
async fn upsert_daily_playtime(
instance_id: &str,
instance_name: &str,
played_on: NaiveDate,
played_seconds: u64,
session_count: u64,
pool: &SqlitePool,
) -> crate::Result<()> {
let played_seconds = playtime_to_storage(played_seconds, "played_seconds")?;
let session_count = playtime_to_storage(session_count, "session_count")?;
let played_on = played_on.format("%Y-%m-%d").to_string();
let max_played_seconds_before_increment = i64::MAX - played_seconds;
let max_session_count_before_increment = i64::MAX - session_count;
sqlx::query!(
"
INSERT INTO instance_daily_playtime (
played_on,
instance_id,
instance_name,
played_seconds,
session_count
)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(played_on, instance_id) DO UPDATE SET
instance_name = excluded.instance_name,
played_seconds = CASE
WHEN instance_daily_playtime.played_seconds > ? THEN ?
ELSE instance_daily_playtime.played_seconds + excluded.played_seconds
END,
session_count = CASE
WHEN instance_daily_playtime.session_count > ? THEN ?
ELSE instance_daily_playtime.session_count + excluded.session_count
END
",
played_on,
instance_id,
instance_name,
played_seconds,
session_count,
max_played_seconds_before_increment,
i64::MAX,
max_session_count_before_increment,
i64::MAX,
)
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn add_instance_recent_playtime(
instance_id: &str,
seconds: u64,
pool: &SqlitePool,
) -> crate::Result<()> {
if seconds == 0 {
return Ok(());
}
let seconds = playtime_to_storage(seconds, "recent_time_played")?;
let max_playtime = i64::MAX;
let max_playtime_before_increment = max_playtime - seconds;
let modified = Utc::now().timestamp();
sqlx::query!(
"
UPDATE instances
SET
recent_time_played = CASE
WHEN recent_time_played < 0 THEN ?
WHEN recent_time_played > ? THEN ?
ELSE recent_time_played + ?
END,
modified = ?
WHERE id = ?
",
seconds,
max_playtime_before_increment,
max_playtime,
seconds,
modified,
instance_id,
)
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn mark_instance_playtime_submitted(
instance_id: &str,
recent_time_played: u64,
pool: &SqlitePool,
) -> crate::Result<()> {
if recent_time_played == 0 {
return Ok(());
}
let recent_time_played =
playtime_to_storage(recent_time_played, "recent_time_played")?;
let max_playtime = i64::MAX;
let max_playtime_before_increment = max_playtime - recent_time_played;
let modified = Utc::now().timestamp();
sqlx::query!(
"
UPDATE instances
SET
submitted_time_played = CASE
WHEN submitted_time_played < 0 THEN ?
WHEN submitted_time_played > ? THEN ?
ELSE submitted_time_played + ?
END,
recent_time_played = 0,
modified = ?
WHERE id = ?
",
recent_time_played,
max_playtime_before_increment,
max_playtime,
recent_time_played,
modified,
instance_id,
)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use chrono_tz::America::New_York;
use sqlx::sqlite::SqlitePoolOptions;
use super::{
get_daily_playtime, set_instance_pinned, split_daily_playtime,
upsert_daily_playtime,
};
fn to_utc(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
) -> chrono::DateTime<Utc> {
New_York
.with_ymd_and_hms(year, month, day, hour, minute, 0)
.single()
.expect("valid New York local time")
.with_timezone(&Utc)
}
#[test]
fn splits_playtime_across_local_midnight() {
let segments = split_daily_playtime(
to_utc(2026, 2, 7, 23, 30),
to_utc(2026, 2, 8, 1, 30),
&New_York,
)
.expect("playtime should split");
assert_eq!(
segments,
vec![
("2026-02-07".parse().unwrap(), 30 * 60),
("2026-02-08".parse().unwrap(), 90 * 60),
],
);
}
#[test]
fn measures_daylight_saving_transitions_by_elapsed_time() {
let spring = split_daily_playtime(
to_utc(2026, 3, 8, 0, 30),
to_utc(2026, 3, 8, 3, 30),
&New_York,
)
.expect("spring daylight saving playtime should split");
let autumn = split_daily_playtime(
to_utc(2026, 11, 1, 0, 30),
to_utc(2026, 11, 1, 2, 30),
&New_York,
)
.expect("autumn daylight saving playtime should split");
assert_eq!(spring[0].1, 2 * 60 * 60);
assert_eq!(autumn[0].1, 3 * 60 * 60);
}
async fn test_pool() -> sqlx::SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory SQLite pool");
sqlx::query(
"CREATE TABLE instances (id TEXT PRIMARY KEY, name TEXT NOT NULL, pinned_at INTEGER NULL, modified INTEGER NOT NULL)",
)
.execute(&pool)
.await
.expect("instances table");
sqlx::query(
"CREATE TABLE instance_daily_playtime (played_on TEXT NOT NULL, instance_id TEXT NOT NULL, instance_name TEXT NOT NULL, played_seconds INTEGER NOT NULL DEFAULT 0, session_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (played_on, instance_id))",
)
.execute(&pool)
.await
.expect("daily playtime table");
pool
}
#[tokio::test]
async fn aggregates_sessions_and_selects_the_top_instance() {
let pool = test_pool().await;
upsert_daily_playtime(
"first",
"First instance",
"2026-07-25".parse().unwrap(),
60,
1,
&pool,
)
.await
.unwrap();
upsert_daily_playtime(
"second",
"Second instance",
"2026-07-25".parse().unwrap(),
240,
2,
&pool,
)
.await
.unwrap();
upsert_daily_playtime(
"first",
"First instance",
"2026-07-25".parse().unwrap(),
300,
1,
&pool,
)
.await
.unwrap();
let summary = get_daily_playtime(
"2026-07-25".parse().unwrap(),
"2026-07-25".parse().unwrap(),
&pool,
)
.await
.unwrap();
assert_eq!(summary[0].played_seconds, 600);
assert_eq!(summary[0].session_count, 4);
assert_eq!(
summary[0].top_instance_name.as_deref(),
Some("First instance")
);
}
#[tokio::test]
async fn persists_pinned_state_without_erasing_playtime_history() {
let pool = test_pool().await;
sqlx::query("INSERT INTO instances (id, name, pinned_at, modified) VALUES ('instance', 'Instance', NULL, 0)")
.execute(&pool)
.await
.unwrap();
upsert_daily_playtime(
"instance",
"Instance",
"2026-07-25".parse().unwrap(),
120,
1,
&pool,
)
.await
.unwrap();
set_instance_pinned("instance", true, &pool).await.unwrap();
let pinned_at: Option<i64> = sqlx::query_scalar(
"SELECT pinned_at FROM instances WHERE id = 'instance'",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(pinned_at.is_some());
set_instance_pinned("instance", false, &pool).await.unwrap();
sqlx::query("DELETE FROM instances WHERE id = 'instance'")
.execute(&pool)
.await
.unwrap();
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM instance_daily_playtime WHERE instance_id = 'instance'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows, 1);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,75 @@
mod create_instance;
pub use self::create_instance::CreateInstance;
pub(crate) use self::create_instance::create_instance;
mod create_direct_link_instance;
pub use self::create_direct_link_instance::CreateDirectLinkInstance;
pub(crate) use self::create_direct_link_instance::create_direct_link_instance;
mod sync_direct_link_instances;
pub(crate) use self::sync_direct_link_instances::sync_direct_link_instances;
pub use self::sync_direct_link_instances::{
DirectLinkSyncReport, ExternalMinecraftRoot,
};
mod edit_instance;
pub use self::edit_instance::{
AppliedContentSetPatch, EditInstance, InstanceLaunchOverridesPatch,
};
pub(crate) use self::edit_instance::{
edit_instance, restore_instance_metadata,
};
mod get_instance;
pub use self::get_instance::InstanceMetadata;
pub(crate) use self::get_instance::{
get_instance, get_instance_metadata, get_instances_metadata, list_instances,
};
mod list_content;
pub(crate) use self::list_content::{
dependencies_to_content_items, get_content_projects,
get_installed_project_ids_for_instance, get_instance_install_candidates,
get_linked_modpack_info, list_content, list_content_by_paths,
list_content_sets, list_linked_modpack_content,
};
mod content_snapshot;
pub(crate) use self::content_snapshot::{
get_content_snapshot, reconcile_curseforge_members,
};
mod remove_instance;
pub(crate) use self::remove_instance::*;
mod refresh_instances;
pub(crate) use self::refresh_instances::*;
mod sync_content_files;
pub(crate) use self::sync_content_files::{
instance_content_root, sync_content_files,
};
mod launch_context;
pub(crate) use self::launch_context::*;
mod apply_content_install;
pub(crate) use self::apply_content_install::*;
mod check_content_updates;
mod instance_upgrade;
pub(crate) use self::instance_upgrade::{
ReadOnlyUpgradeSource, UpgradePlanRuntimeValidation,
create_instance_upgrade_plan_with_source,
recompute_instance_upgrade_plan_from_source,
scan_instance_upgrade_source_files, validate_instance_upgrade_plan_source,
};
mod post_upgrade_notice;
pub(crate) use self::post_upgrade_notice::*;
mod apply_content_update;
pub(crate) use self::apply_content_update::*;
mod import_world_save;
pub(crate) use self::import_world_save::import_world_save;
@@ -0,0 +1,526 @@
use sqlx::{Row, SqliteConnection, SqlitePool};
use crate::state::{InstancePostUpgradeNotice, InstancePostUpgradeWarning};
pub(crate) async fn get_instance_post_upgrade_notice(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstancePostUpgradeNotice>> {
let row = sqlx::query(
"SELECT upgrade_job_id, target_game_version, consecutive_clean_launches, warnings_json FROM instance_post_upgrade_notices WHERE instance_id = ?",
)
.bind(instance_id)
.fetch_optional(pool)
.await?;
row.map(|row| {
let warnings_json: String = row.try_get("warnings_json")?;
Ok(InstancePostUpgradeNotice {
instance_id: instance_id.to_string(),
upgrade_job_id: row.try_get("upgrade_job_id")?,
target_game_version: row.try_get("target_game_version")?,
consecutive_clean_launches: row
.try_get::<i64, _>("consecutive_clean_launches")?
.clamp(0, u8::MAX as i64)
as u8,
warnings: serde_json::from_str(&warnings_json)?,
})
})
.transpose()
}
pub(crate) async fn replace_instance_post_upgrade_notice(
notice: &InstancePostUpgradeNotice,
pool: &SqlitePool,
) -> crate::Result<()> {
let mut connection = pool.acquire().await?;
replace_instance_post_upgrade_notice_on_connection(notice, &mut connection)
.await
}
pub(crate) async fn replace_instance_post_upgrade_notice_on_connection(
notice: &InstancePostUpgradeNotice,
connection: &mut SqliteConnection,
) -> crate::Result<()> {
if notice.warnings.is_empty() {
sqlx::query(
"DELETE FROM instance_post_upgrade_notices WHERE instance_id = ?",
)
.bind(&notice.instance_id)
.execute(&mut *connection)
.await?;
return Ok(());
}
sqlx::query(
"INSERT INTO instance_post_upgrade_notices (instance_id, upgrade_job_id, target_game_version, consecutive_clean_launches, warnings_json) VALUES (?, ?, ?, ?, ?) ON CONFLICT(instance_id) DO UPDATE SET upgrade_job_id = excluded.upgrade_job_id, target_game_version = excluded.target_game_version, consecutive_clean_launches = excluded.consecutive_clean_launches, warnings_json = excluded.warnings_json, modified = CURRENT_TIMESTAMP",
)
.bind(&notice.instance_id)
.bind(&notice.upgrade_job_id)
.bind(&notice.target_game_version)
.bind(i64::from(notice.consecutive_clean_launches))
.bind(serde_json::to_string(&notice.warnings)?)
.execute(&mut *connection)
.await?;
Ok(())
}
pub(crate) async fn dismiss_instance_post_upgrade_notice(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
sqlx::query(
"DELETE FROM instance_post_upgrade_notices WHERE instance_id = ?",
)
.bind(instance_id)
.execute(pool)
.await?;
Ok(())
}
pub(crate) fn next_post_upgrade_notice_after_launch(
mut notice: InstancePostUpgradeNotice,
clean: bool,
) -> Option<InstancePostUpgradeNotice> {
if clean {
notice.consecutive_clean_launches =
notice.consecutive_clean_launches.saturating_add(1);
(notice.consecutive_clean_launches < 2).then_some(notice)
} else {
notice.consecutive_clean_launches = 0;
Some(notice)
}
}
pub(crate) async fn record_instance_post_upgrade_launch(
instance_id: &str,
clean: bool,
pool: &SqlitePool,
) -> crate::Result<()> {
let Some(notice) =
get_instance_post_upgrade_notice(instance_id, pool).await?
else {
return Ok(());
};
match next_post_upgrade_notice_after_launch(notice, clean) {
Some(notice) => {
replace_instance_post_upgrade_notice(&notice, pool).await
}
None => dismiss_instance_post_upgrade_notice(instance_id, pool).await,
}
}
pub(crate) fn post_upgrade_warnings_from_result(
result: &crate::install::InstanceUpgradeResult,
execution: &crate::install::InstanceUpgradeExecution,
) -> Vec<InstancePostUpgradeWarning> {
use crate::state::{InstanceUpgradeAction, InstanceUpgradeIssueCode};
result
.compatibility_warning_details
.iter()
.filter_map(|warning| {
if warning.content_id.is_none() && warning.relative_path.is_none() {
return None;
}
let action = warning
.content_id
.as_ref()
.and_then(|content_id| {
result
.solution
.selections
.iter()
.find(|selection| selection.content_id == *content_id)
})
.or_else(|| {
let provider = warning.provider?;
let project_id = warning.project_id.as_deref()?;
let mut matches =
result.solution.selections.iter().filter(|selection| {
selection.provider == Some(provider)
&& selection.project_id.as_deref()
== Some(project_id)
});
let selection = matches.next()?;
matches.next().is_none().then_some(selection)
})
.map(|selection| selection.action);
let action = action.or_else(|| {
let item = warning
.content_id
.as_ref()
.and_then(|content_id| {
execution
.items
.iter()
.find(|item| item.content_id == *content_id)
})
.or_else(|| {
let relative_path = warning.relative_path.as_deref()?;
execution
.items
.iter()
.find(|item| item.relative_path == relative_path)
})?;
Some(execution.final_physical_decision(item).0)
});
let code = match action {
Some(
InstanceUpgradeAction::Upgrade
| InstanceUpgradeAction::Disable,
) => {
return None;
}
Some(InstanceUpgradeAction::Keep) => match warning.code {
InstanceUpgradeIssueCode::PrereleaseOnly
| InstanceUpgradeIssueCode::DependencyConflict
| InstanceUpgradeIssueCode::MissingRequiredDependency
| InstanceUpgradeIssueCode::IncompatibleDependency
| InstanceUpgradeIssueCode::SearchLimitReached => {
InstanceUpgradeIssueCode::KeepIncompatible
}
code => code,
},
None => match warning.code {
InstanceUpgradeIssueCode::Unidentified
| InstanceUpgradeIssueCode::UnsupportedContentType
| InstanceUpgradeIssueCode::KeepIncompatible => {
warning.code
}
_ => return None,
},
};
Some(InstancePostUpgradeWarning {
code,
content_id: warning.content_id.clone(),
relative_path: warning.relative_path.clone(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::install::{
InstanceUpgradeCompatibilityWarning, InstanceUpgradeExecution,
InstanceUpgradeResult,
};
use crate::state::{
InstanceUpgradeAction, InstanceUpgradeEnvironment,
InstanceUpgradeIssueCode, InstanceUpgradeItem,
InstanceUpgradeItemStatus, InstanceUpgradeResolution,
InstanceUpgradeSelection, InstanceUpgradeSolution,
InstanceUpgradeSolutionKind, ModLoader, ProjectType, ShaderRuntime,
};
fn empty_execution() -> InstanceUpgradeExecution {
let environment = InstanceUpgradeEnvironment {
game_version: "1.21.9".to_string(),
mod_loader: ModLoader::Fabric,
mod_loader_version: Some("0.18.5".to_string()),
shader_runtime: ShaderRuntime::Iris,
};
InstanceUpgradeExecution {
source_revision: 1,
source_files: Vec::new(),
source_environment: environment.clone(),
target_environment: environment,
items: Vec::new(),
solution: InstanceUpgradeSolution {
kind: InstanceUpgradeSolutionKind::Custom,
selections: Vec::new(),
dependency_changes: Vec::new(),
warnings: Vec::new(),
},
warnings: Vec::new(),
source_watch: None,
}
}
fn upgrade_result(
entries: &[(
&str,
InstanceUpgradeIssueCode,
InstanceUpgradeAction,
Option<&str>,
)],
) -> InstanceUpgradeResult {
InstanceUpgradeResult {
plan_id: "plan".to_string(),
source_instance_id: "instance".to_string(),
target_instance_id: "instance".to_string(),
backup_instance_id: None,
source_environment: None,
target_environment: None,
solution: InstanceUpgradeSolution {
kind: InstanceUpgradeSolutionKind::Custom,
selections: entries
.iter()
.map(|(content_id, _, action, target_release_id)| {
InstanceUpgradeSelection {
content_id: (*content_id).to_string(),
provider: None,
project_id: None,
current_release_id: None,
target_release_id: target_release_id
.map(str::to_string),
action: *action,
enabled: *action != InstanceUpgradeAction::Disable,
}
})
.collect(),
dependency_changes: Vec::new(),
warnings: Vec::new(),
},
compatibility_warnings: Vec::new(),
compatibility_warning_details: entries
.iter()
.map(|(content_id, code, _, _)| {
InstanceUpgradeCompatibilityWarning {
code: *code,
relative_path: Some(format!("mods/{content_id}.jar")),
content_id: Some((*content_id).to_string()),
provider: None,
project_id: None,
conflicting_project_id: None,
}
})
.collect(),
external_changes: Vec::new(),
skipped_due_to_external_conflict: Vec::new(),
}
}
fn notice(clean_launches: u8) -> InstancePostUpgradeNotice {
InstancePostUpgradeNotice {
instance_id: "instance".to_string(),
upgrade_job_id: "job".to_string(),
target_game_version: "26.2".to_string(),
consecutive_clean_launches: clean_launches,
warnings: vec![InstancePostUpgradeWarning {
code: InstanceUpgradeIssueCode::KeepIncompatible,
content_id: Some("content".to_string()),
relative_path: Some("mods/example.jar".to_string()),
}],
}
}
#[test]
fn clean_launch_expires_notice_after_two_consecutive_sessions() {
let first = next_post_upgrade_notice_after_launch(notice(0), true)
.expect("first launch keeps notice");
assert_eq!(first.consecutive_clean_launches, 1);
assert!(next_post_upgrade_notice_after_launch(first, true).is_none());
}
#[test]
fn failed_launch_resets_consecutive_count() {
let reset = next_post_upgrade_notice_after_launch(notice(1), false)
.expect("failed launch keeps notice");
assert_eq!(reset.consecutive_clean_launches, 0);
}
#[test]
fn upgraded_prerelease_history_is_not_a_post_upgrade_risk() {
let result = upgrade_result(&[(
"voxy",
InstanceUpgradeIssueCode::PrereleaseOnly,
InstanceUpgradeAction::Upgrade,
Some("target-alpha"),
)]);
assert!(
post_upgrade_warnings_from_result(&result, &empty_execution())
.is_empty()
);
assert_eq!(result.compatibility_warning_details.len(), 1);
}
#[test]
fn kept_prerelease_is_reported_as_incompatible_preserved_content() {
let result = upgrade_result(&[(
"voxy",
InstanceUpgradeIssueCode::PrereleaseOnly,
InstanceUpgradeAction::Keep,
None,
)]);
let warnings =
post_upgrade_warnings_from_result(&result, &empty_execution());
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].code,
InstanceUpgradeIssueCode::KeepIncompatible
);
}
#[test]
fn kept_no_release_and_unidentified_content_remain_risks() {
let result = upgrade_result(&[
(
"resource-pack",
InstanceUpgradeIssueCode::NoCompatibleRelease,
InstanceUpgradeAction::Keep,
None,
),
(
"local-jar",
InstanceUpgradeIssueCode::Unidentified,
InstanceUpgradeAction::Keep,
None,
),
]);
let warnings =
post_upgrade_warnings_from_result(&result, &empty_execution());
assert_eq!(warnings.len(), 2);
assert!(warnings.iter().any(|warning| {
warning.code == InstanceUpgradeIssueCode::NoCompatibleRelease
}));
assert!(warnings.iter().any(|warning| {
warning.code == InstanceUpgradeIssueCode::Unidentified
}));
}
#[test]
fn disabled_content_is_not_a_post_upgrade_risk() {
let result = upgrade_result(&[(
"disabled",
InstanceUpgradeIssueCode::NoCompatibleRelease,
InstanceUpgradeAction::Disable,
None,
)]);
assert!(
post_upgrade_warnings_from_result(&result, &empty_execution())
.is_empty()
);
}
#[test]
fn global_history_warning_is_not_a_content_notice() {
let mut result = upgrade_result(&[(
"global",
InstanceUpgradeIssueCode::KeepIncompatible,
InstanceUpgradeAction::Keep,
None,
)]);
result.compatibility_warning_details[0].content_id = None;
result.compatibility_warning_details[0].relative_path = None;
assert!(
post_upgrade_warnings_from_result(&result, &empty_execution())
.is_empty()
);
}
#[test]
fn mixed_final_actions_only_report_kept_content() {
let result = upgrade_result(&[
(
"upgraded",
InstanceUpgradeIssueCode::PrereleaseOnly,
InstanceUpgradeAction::Upgrade,
Some("target-alpha"),
),
(
"kept",
InstanceUpgradeIssueCode::NoCompatibleRelease,
InstanceUpgradeAction::Keep,
None,
),
(
"disabled",
InstanceUpgradeIssueCode::NoCompatibleRelease,
InstanceUpgradeAction::Disable,
None,
),
]);
let warnings =
post_upgrade_warnings_from_result(&result, &empty_execution());
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].content_id.as_deref(), Some("kept"));
}
#[test]
fn non_solver_local_notice_uses_execution_item_resolution() {
let mut result = upgrade_result(&[(
"local",
InstanceUpgradeIssueCode::Unidentified,
InstanceUpgradeAction::Keep,
None,
)]);
result.solution.selections.clear();
let mut execution = empty_execution();
execution.items.push(InstanceUpgradeItem {
content_id: "local".to_string(),
relative_path: "mods/local.jar".to_string(),
project_type: ProjectType::Mod,
provider: None,
project_id: None,
current_release_id: None,
current_enabled: true,
auto_dependency: false,
status: InstanceUpgradeItemStatus::Unidentified,
resolution: InstanceUpgradeResolution {
content_id: "local".to_string(),
action: InstanceUpgradeAction::Keep,
allow_prerelease: false,
confirmed_prerelease_dependencies: Vec::new(),
},
candidate_release_ids: Vec::new(),
});
assert_eq!(
post_upgrade_warnings_from_result(&result, &execution).len(),
1
);
execution.items[0].resolution.action = InstanceUpgradeAction::Disable;
assert!(
post_upgrade_warnings_from_result(&result, &execution).is_empty()
);
}
#[tokio::test]
async fn dismiss_removes_persisted_notice() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query("CREATE TABLE instances (id TEXT PRIMARY KEY NOT NULL)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO instances (id) VALUES ('instance')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE instance_post_upgrade_notices (instance_id TEXT PRIMARY KEY NOT NULL REFERENCES instances(id) ON DELETE CASCADE, upgrade_job_id TEXT NOT NULL, target_game_version TEXT NOT NULL, consecutive_clean_launches INTEGER NOT NULL DEFAULT 0, warnings_json TEXT NOT NULL, created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)",
)
.execute(&pool)
.await
.unwrap();
replace_instance_post_upgrade_notice(&notice(0), &pool)
.await
.unwrap();
assert!(
get_instance_post_upgrade_notice("instance", &pool)
.await
.unwrap()
.is_some()
);
dismiss_instance_post_upgrade_notice("instance", &pool)
.await
.unwrap();
assert!(
get_instance_post_upgrade_notice("instance", &pool)
.await
.unwrap()
.is_none()
);
}
}
@@ -0,0 +1,35 @@
use crate::State;
use crate::state::LauncherFeatureVersion;
use super::edit_instance::EditInstance;
pub(crate) async fn refresh_all_instances() -> crate::Result<()> {
let state = State::get().await?;
let instances = crate::state::instances::adapters::sqlite::instance_rows::list_instances(
&state.pool,
)
.await?;
for instance in instances {
let launcher_feature_version = (instance.launcher_feature_version
< LauncherFeatureVersion::MOST_RECENT)
.then_some(LauncherFeatureVersion::MOST_RECENT);
if launcher_feature_version.is_none() {
continue;
}
super::edit_instance::edit_instance(
&instance.id,
EditInstance {
install_stage: None,
launcher_feature_version,
..EditInstance::default()
},
&state.pool,
)
.await?;
}
Ok(())
}
@@ -0,0 +1,71 @@
use crate::state::State;
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::instances::config_sync;
use crate::util::io;
use std::path::{Path, PathBuf};
pub(crate) async fn remove_instance(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
let _instance_lock = state.lock_instance_content(instance_id).await;
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
// Directly associated instances have no Axolotl profile directory. Their
// version directory is the instance itself, so removal deliberately
// deletes the externally managed content in place. Keep the shared
// `.minecraft` root (assets/libraries/other versions) intact.
let path = if instance.is_direct_linked() {
crate::launcher::DirectLinkedLaunch::from_instance(&instance)?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(
"Direct instance link metadata is incomplete".to_string(),
)
})?
.version_dir()
} else if let Some(game_dir_override) = instance
.game_dir_override
.as_deref()
.map(PathBuf::from)
.filter(|path| is_version_isolated_game_dir(path))
{
// New instances created against a configured `.minecraft` root use
// a private `versions/<name>` directory. Remove that external
// directory when the instance is deleted, while preserving shared
// (non-isolated) overrides for backwards compatibility.
game_dir_override
} else {
state.directories.instances_dir().join(&instance.path)
};
if path.exists() {
io::remove_dir_all(&path).await?;
}
let jobs = crate::install::store::mark_instance_deleted(instance_id, state)
.await?;
instance_rows::delete_instance_by_id(&instance.id, &state.pool).await?;
config_sync::remove_config_file(&state.directories, &instance.path).await?;
for job in jobs {
if let Err(error) =
crate::install::events::emit_install_job(&job.snapshot()).await
{
tracing::warn!(
"Failed to emit deleted instance download state: {error}"
);
}
}
Ok(())
}
fn is_version_isolated_game_dir(path: &Path) -> bool {
path.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
== Some("versions")
}
@@ -0,0 +1,974 @@
use crate::State;
use crate::state::instances::adapters::{filesystem, sqlite};
use crate::state::instances::{Instance, InstanceFile};
use crate::state::{
CacheBehaviour, CachedEntry, CachedFileUpdate, ContentProvider,
ContentProviderRef, DirectoryInfo, ModrinthVersionId, ProjectType,
};
use crate::util::fetch::{self, FetchSemaphore};
use crate::util::io;
use chrono::Utc;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use uuid::Uuid;
/// Resolves the directory whose game content (`mods`, `resourcepacks`, ...)
/// belongs to this instance.
///
/// Ordinary instances own their profile directory under Axolotl's instances
/// folder, honouring a per-instance `game_dir_override`
/// (`DirectoryInfo::instance_game_dir`). Directly associated instances have no
/// profile directory: their content lives inside the externally managed
/// installation, resolved through the launcher dialect so PCL version
/// isolation (`versions/<id>` gameDir) is honored; the shared linked
/// `.minecraft` root is the fallback when the dialect resolution cannot be
/// completed (see `launcher::linked_game_dir`).
pub(crate) fn instance_content_root(
directories: &DirectoryInfo,
instance: &Instance,
) -> crate::Result<PathBuf> {
if let Some(game_dir) =
crate::launcher::linked_game_dir(instance).or_else(|| {
instance
.linked_dot_minecraft
.as_deref()
.map(str::trim)
.filter(|linked| !linked.is_empty())
.map(PathBuf::from)
})
{
return Ok(io::canonicalize(game_dir)?);
}
Ok(io::canonicalize(directories.instance_game_dir(instance))?)
}
/// Joins a stored logical relative path using native path components. Content
/// rows intentionally use `/` as their cross-platform serialization format;
/// filesystem access must not concatenate that representation directly.
pub(crate) fn join_content_path(root: &Path, relative: &str) -> PathBuf {
relative
.split(['/', '\\'])
.filter(|component| !component.is_empty() && *component != ".")
.fold(root.to_path_buf(), |path, component| path.join(component))
}
pub(crate) async fn sync_content_files(
instance_id: &str,
state: &State,
) -> crate::Result<Vec<InstanceFile>> {
let instance =
sqlite::instance_rows::get_instance_by_id(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
sync_instance_content_files(&instance, state).await
}
pub(crate) async fn sync_instance_content_files(
instance: &Instance,
state: &State,
) -> crate::Result<Vec<InstanceFile>> {
// Keep the filesystem snapshot stable until its database rows commit.
let _instance_lock = state.lock_instance_content(&instance.id).await;
let content_root = instance_content_root(&state.directories, instance)?;
// The hash-cache layer resolves key paths against Axolotl's own instances
// folder (`state/cache.rs`). For directly associated instances the
// absolute linked root is passed as the "instance path": joining an
// absolute path replaces the base, so the cache layer hashes the linked
// files instead of failing on the nonexistent profile path. Managed
// instances keep the relative profile path so the cache layer can resolve
// a `game_dir_override` target from the database.
let is_direct_linked = crate::launcher::linked_game_dir(instance).is_some()
|| instance
.linked_dot_minecraft
.as_deref()
.map(str::trim)
.is_some_and(|linked| !linked.is_empty());
let instance_files_root = if is_direct_linked {
content_root.clone()
} else {
state.directories.instances_dir().join(&instance.path)
};
let cache_key_path = if is_direct_linked {
content_root.to_string_lossy().into_owned()
} else {
instance.path.clone()
};
let scanned =
filesystem::scan_content_files_from(&content_root, &cache_key_path)?;
let scanned_paths = scanned
.iter()
.map(|file| file.relative_path.clone())
.collect::<HashSet<_>>();
let cache_keys = scanned
.iter()
.map(|file| file.hash_cache_key.as_str())
.collect::<Vec<_>>();
let hashes = CachedEntry::get_file_hash_many(
&cache_keys,
None,
&state.pool,
&state.api_semaphore,
)
.await?;
let hashes_by_key = hashes
.into_iter()
.map(|hash| {
(
format!(
"{}-{}",
hash.size,
hash.path.trim_end_matches(".disabled")
),
hash,
)
})
.collect::<HashMap<_, _>>();
let existing_files =
sqlite::content_rows::get_instance_files(&instance.id, &state.pool)
.await?;
let mut existing_files_by_path = HashMap::new();
let mut existing_files_by_sha1: HashMap<String, Vec<InstanceFile>> =
HashMap::new();
for file in existing_files {
existing_files_by_sha1
.entry(file.sha1.clone())
.or_default()
.push(file.clone());
existing_files_by_path.insert(file.relative_path.clone(), file);
}
let content_set = sqlite::content_rows::get_applied_content_set(
&instance.id,
&state.pool,
)
.await?;
let entry_file_ids = match content_set.as_ref() {
Some(content_set) => sqlite::content_rows::get_content_entries(
&content_set.id,
&state.pool,
)
.await?
.into_iter()
.filter_map(|entry| entry.file_id)
.collect(),
None => HashSet::new(),
};
let now = Utc::now();
let mut files: Vec<InstanceFile> = Vec::new();
let mut reclaims: HashMap<String, String> = HashMap::new();
let mut merges: HashMap<String, String> = HashMap::new();
let mut claimed_reclaim_ids = HashSet::new();
let mut externally_changed_file_ids = HashSet::new();
for file in scanned {
let hash_key = file.hash_cache_key.trim_end_matches(".disabled");
let existing_file = existing_files_by_path.get(&file.relative_path);
let (scanned_sha1, scanned_size) = if existing_file.is_some() {
let path =
join_content_path(&instance_files_root, &file.relative_path);
let (_, sha1) = fetch::sha1_file_async(&path).await?;
(sha1, file.size)
} else {
let Some(hash) = hashes_by_key.get(hash_key) else {
continue;
};
(hash.hash.clone(), hash.size)
};
let reclaim_candidate = if existing_file.is_some() {
None
} else {
reclaimable_existing_file(
&scanned_sha1,
&file.relative_path,
&existing_files_by_sha1,
&scanned_paths,
&claimed_reclaim_ids,
)
};
let merge_candidate = existing_file
.filter(|file| !entry_file_ids.contains(&file.id))
.and_then(|file| {
mergeable_tracked_file(
&scanned_sha1,
&file.relative_path,
&file.id,
&existing_files_by_sha1,
&entry_file_ids,
&scanned_paths,
&claimed_reclaim_ids,
)
});
let source_file = existing_file.or(reclaim_candidate);
if let Some(existing_file) = existing_file
&& physical_file_identity_changed(
existing_file,
&scanned_sha1,
scanned_size,
)
{
externally_changed_file_ids.insert(existing_file.id.clone());
}
if let Some(candidate) = reclaim_candidate {
claimed_reclaim_ids.insert(candidate.id.clone());
reclaims.insert(
file.relative_path.clone(),
candidate.relative_path.clone(),
);
}
if let Some(candidate) = merge_candidate {
claimed_reclaim_ids.insert(candidate.id.clone());
merges.insert(file.relative_path.clone(), candidate.id.clone());
}
files.push(InstanceFile {
id: source_file
.map(|file| file.id.clone())
.unwrap_or_else(instance_file_id),
instance_id: instance.id.clone(),
relative_path: file.relative_path,
file_name: file.file_name,
enabled: file.enabled,
sha1: scanned_sha1,
size: scanned_size,
missing: false,
added_at: source_file.map(|file| file.added_at).unwrap_or(now),
modified_at: now,
local_mod_data: source_file.and_then(|f| f.local_mod_data.clone()),
icon_path: source_file.and_then(|f| f.icon_path.clone()),
});
}
// Extract local mod metadata (Mod JARs) and cached icons (Mod JARs and
// resource packs) for files that don't have them yet. This also backfills
// rows created before these features existed; `icon_path` distinguishes
// not-attempted (NULL), no-icon (empty string), and cached (path).
// `content_root` already resolves the override / linked root consistently
// for both managed and directly associated instances.
let instance_dir = content_root;
let icon_cache_dir = state.directories.caches_dir().join("icons");
for file in &mut files {
let Some(project_type) = project_type_for_file(file) else {
continue;
};
// Re-extract metadata written before dependency extraction existed:
// legacy JSON parses with `dependencies: None` and needs one pass
// through the updated extractor.
let extract_metadata = project_type == ProjectType::Mod
&& file
.local_mod_data
.as_ref()
.and_then(|json| {
serde_json::from_str::<
crate::mod_metadata::LocalModMetadata,
>(json)
.ok()
})
.and_then(|metadata| metadata.dependencies)
.is_none();
let extract_icon = file.icon_path.is_none()
&& matches!(
project_type,
ProjectType::Mod | ProjectType::ResourcePack
);
if !extract_metadata && !extract_icon {
continue;
}
let path = join_content_path(&instance_dir, &file.relative_path);
// Resource packs are read entry-wise so large archives are not
// materialized in memory just to fetch `pack.png`.
if extract_icon && project_type == ProjectType::ResourcePack {
let icon =
crate::mod_metadata::icon::extract_resource_pack_icon(&path);
file.icon_path = Some(
cache_extracted_icon(icon, &file.sha1, &icon_cache_dir, state)
.await,
);
continue;
}
// Mods: one in-memory read serves both metadata and icon extraction.
let bytes = match tokio::fs::read(&path).await {
Ok(data) => bytes::Bytes::from(data),
Err(_) => {
// File temporarily inaccessible; skip silently.
continue;
}
};
if extract_metadata
&& let Some(meta) =
crate::mod_metadata::extract_mod_metadata(&bytes)
&& let Ok(json) = serde_json::to_string(&meta)
{
file.local_mod_data = Some(json);
}
if extract_icon {
let meta = file.local_mod_data.as_ref().and_then(|json| {
serde_json::from_str::<crate::mod_metadata::LocalModMetadata>(
json,
)
.ok()
});
let icon = crate::mod_metadata::icon::extract_mod_icon(
&bytes,
meta.as_ref(),
);
file.icon_path = Some(
cache_extracted_icon(icon, &file.sha1, &icon_cache_dir, state)
.await,
);
}
}
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
sqlite::content_rows::ensure_instance_exists(&instance.id, &mut tx).await?;
sqlite::content_rows::mark_instance_files_missing(&instance.id, &mut tx)
.await?;
let mut invalidated_provider_identity = false;
if let Some(content_set) = content_set.as_ref() {
for file_id in &externally_changed_file_ids {
invalidated_provider_identity |= sqlite::content_rows::invalidate_exact_provider_refs_for_file_in_transaction(
&content_set.id,
file_id,
&mut tx,
)
.await?;
}
}
// Upsert with a fresh id lookup inside the transaction. The ids assigned
// during the scan may be stale if a concurrent operation (e.g. batch
// disable renaming files to `.disabled`) moved a row after the snapshot;
// reusing a stale id against the moved row would trip the UNIQUE
// constraint on `instance_files.id` (code 1555).
let mut synced_files: Vec<InstanceFile> = Vec::with_capacity(files.len());
for file in &files {
let synced =
if let Some(tracked_file_id) = merges.get(&file.relative_path) {
sqlite::content_rows::adopt_untracked_file_in_transaction(
&instance.id,
&file.relative_path,
tracked_file_id,
&mut tx,
)
.await?;
upsert_scanned_file(&instance.id, file, &mut tx).await?
} else if let Some(old_relative_path) =
reclaims.get(&file.relative_path)
{
match sqlite::content_rows::move_instance_file_in_transaction(
&instance.id,
old_relative_path,
&file.relative_path,
&file.file_name,
file.enabled,
&file.sha1,
file.size,
file.local_mod_data.as_deref(),
file.icon_path.as_deref(),
&mut tx,
)
.await?
{
Some(file) => file,
None => {
upsert_scanned_file(&instance.id, file, &mut tx).await?
}
}
} else {
upsert_scanned_file(&instance.id, file, &mut tx).await?
};
synced_files.push(synced);
}
if invalidated_provider_identity
&& let Some(content_set) = content_set.as_ref()
{
sqlite::content_rows::bump_content_set_revision_in_transaction(
&content_set.id,
&mut tx,
)
.await?;
}
tx.commit().await?;
Ok(synced_files)
}
async fn cache_extracted_icon(
icon: Option<(String, Vec<u8>)>,
sha1: &str,
icon_cache_dir: &Path,
state: &State,
) -> String {
let Some((entry_name, icon_bytes)) = icon else {
return String::new();
};
let extension = icon_extension(&entry_name);
let cache_path = icon_cache_dir.join(format!("{sha1}.{extension}"));
match fetch::write(&cache_path, &icon_bytes, &state.io_semaphore).await {
Ok(()) => crate::util::io::canonicalize(&cache_path)
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|_| cache_path.to_string_lossy().into_owned()),
Err(_) => String::new(),
}
}
fn icon_extension(entry_name: &str) -> &str {
let extension = Path::new(entry_name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
if matches!(
extension.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg"
) {
extension
} else {
"png"
}
}
pub(crate) fn project_type_for_file(
file: &InstanceFile,
) -> Option<ProjectType> {
filesystem::project_type_from_relative_path(&file.relative_path)
}
pub(crate) fn installed_modrinth_version_id(
provider_refs: &[ContentProviderRef],
) -> Option<ModrinthVersionId> {
provider_refs.iter().find_map(|reference| match reference {
ContentProviderRef::Modrinth { version_id, .. } => {
version_id.as_ref().cloned()
}
ContentProviderRef::CurseForge { .. }
| ContentProviderRef::McArchive { .. } => None,
})
}
pub(crate) async fn fetch_content_file_updates(
update_key_refs: &[&str],
cache_behaviour: Option<CacheBehaviour>,
refresh: bool,
pool: &sqlx::SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<CachedFileUpdate>> {
let update_behaviour = if refresh {
Some(CacheBehaviour::Bypass)
} else {
cache_behaviour
};
match CachedEntry::get_file_update_many(
update_key_refs,
update_behaviour,
pool,
fetch_semaphore,
)
.await
{
Ok(updates) => Ok(updates),
Err(error) if refresh => {
tracing::warn!(
"Content update refresh failed, using cached update data: {error}"
);
CachedEntry::get_file_update_many(
update_key_refs,
Some(CacheBehaviour::CacheOnly),
pool,
fetch_semaphore,
)
.await
}
Err(error) => Err(error),
}
}
/// Whether a file may receive Modrinth update suggestions.
///
/// Files installed from Modrinth (origin `Modrinth`) always qualify. Untracked
/// or locally recorded files (no origin) qualify as long as no CurseForge
/// reference ties them to a different provider; CurseForge-origin files are
/// handled by the CurseForge update path instead.
pub(crate) fn modrinth_update_enabled(
origin_provider: Option<ContentProvider>,
provider_refs: &[ContentProviderRef],
) -> bool {
match origin_provider {
Some(ContentProvider::Modrinth) => true,
Some(ContentProvider::CurseForge)
| Some(ContentProvider::McArchive)
| Some(ContentProvider::Local) => false,
None => provider_refs.iter().all(|reference| {
matches!(reference, ContentProviderRef::Modrinth { .. })
}),
}
}
fn instance_file_id() -> String {
format!("instance-file:{}", Uuid::new_v4())
}
fn physical_file_identity_changed(
existing: &InstanceFile,
scanned_sha1: &str,
scanned_size: u64,
) -> bool {
existing.sha1 != scanned_sha1 || existing.size != scanned_size
}
async fn upsert_scanned_file(
instance_id: &str,
file: &InstanceFile,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
) -> crate::Result<InstanceFile> {
sqlite::content_rows::upsert_instance_file_from_parts_in_transaction(
sqlite::content_rows::UpsertInstanceFile {
instance_id,
relative_path: &file.relative_path,
file_name: &file.file_name,
enabled: file.enabled,
sha1: &file.sha1,
size: file.size,
missing: false,
local_mod_data: file.local_mod_data.as_deref(),
icon_path: file.icon_path.as_deref(),
},
tx,
)
.await
}
/// Finds the single missing file row that can safely inherit a scanned file's
/// installed identity. A rename should keep its provider refs, entry, and
/// install history; ambiguous duplicates and cross-type moves stay untracked.
fn reclaimable_existing_file<'a>(
sha1: &str,
new_relative_path: &str,
existing_files_by_sha1: &'a HashMap<String, Vec<InstanceFile>>,
scanned_paths: &HashSet<String>,
claimed_ids: &HashSet<String>,
) -> Option<&'a InstanceFile> {
let candidates = existing_files_by_sha1.get(sha1)?;
if candidates
.iter()
.any(|file| scanned_paths.contains(file.relative_path.as_str()))
{
return None;
}
let missing = candidates
.iter()
.filter(|file| {
file.missing || !scanned_paths.contains(&file.relative_path)
})
.collect::<Vec<_>>();
if missing.len() != 1 || claimed_ids.contains(&missing[0].id) {
return None;
}
let candidate = missing[0];
let new_type =
filesystem::project_type_from_relative_path(new_relative_path)?;
let old_type =
filesystem::project_type_from_relative_path(&candidate.relative_path)?;
(new_type == old_type).then_some(candidate)
}
/// Finds the single tracked row that should hand its installed identity to an
/// untracked row at a new path. This repairs instances that were already
/// broken by a rename before reclaim-on-move existed.
fn mergeable_tracked_file<'a>(
sha1: &str,
new_relative_path: &str,
untracked_file_id: &str,
existing_files_by_sha1: &'a HashMap<String, Vec<InstanceFile>>,
entry_file_ids: &HashSet<String>,
scanned_paths: &HashSet<String>,
claimed_ids: &HashSet<String>,
) -> Option<&'a InstanceFile> {
let candidates = existing_files_by_sha1.get(sha1)?;
let tracked = candidates
.iter()
.filter(|file| entry_file_ids.contains(&file.id))
.collect::<Vec<_>>();
if tracked.is_empty()
|| tracked.iter().any(|file| {
file.id != untracked_file_id
&& scanned_paths.contains(file.relative_path.as_str())
})
{
return None;
}
let missing = tracked
.iter()
.filter(|file| {
file.id != untracked_file_id
&& (file.missing
|| !scanned_paths.contains(file.relative_path.as_str()))
})
.collect::<Vec<_>>();
if missing.len() != 1 || claimed_ids.contains(&missing[0].id) {
return None;
}
let candidate = missing[0];
let new_type =
filesystem::project_type_from_relative_path(new_relative_path)?;
let old_type =
filesystem::project_type_from_relative_path(&candidate.relative_path)?;
(new_type == old_type).then_some(candidate)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{
CurseForgeFileId, CurseForgeProjectId, ModrinthProjectId,
ModrinthVersionId,
};
use std::fs;
use std::sync::Arc;
fn modrinth_ref() -> ContentProviderRef {
ContentProviderRef::Modrinth {
project_id: ModrinthProjectId::new("project").unwrap(),
version_id: Some(ModrinthVersionId::new("version").unwrap()),
}
}
fn curseforge_ref() -> ContentProviderRef {
ContentProviderRef::CurseForge {
project_id: CurseForgeProjectId::new(42).unwrap(),
file_id: Some(CurseForgeFileId::new(7).unwrap()),
}
}
#[test]
fn untracked_files_qualify_for_modrinth_updates() {
assert!(modrinth_update_enabled(None, &[]));
assert!(modrinth_update_enabled(None, &[modrinth_ref()]));
}
#[test]
fn curseforge_tracked_files_do_not_qualify_for_modrinth_updates() {
assert!(!modrinth_update_enabled(
Some(ContentProvider::CurseForge),
&[curseforge_ref()],
));
assert!(!modrinth_update_enabled(None, &[curseforge_ref()]));
}
#[test]
fn modrinth_origin_always_qualifies() {
assert!(modrinth_update_enabled(
Some(ContentProvider::Modrinth),
&[curseforge_ref(), modrinth_ref()],
));
}
#[test]
fn content_scan_preserves_install_temporary_files() {
let root = tempfile::tempdir().unwrap();
let mods = root.path().join("instance/mods");
fs::create_dir_all(&mods).unwrap();
let temporary_names = [
"example.jar.installing.download",
"example.jar.installing",
"example.jar.installing.previous",
];
for name in temporary_names {
fs::write(mods.join(name), name).unwrap();
}
let scanned =
filesystem::scan_content_files(root.path(), "instance").unwrap();
assert!(scanned.is_empty());
for name in temporary_names {
assert!(mods.join(name).is_file());
}
}
// -----------------------------------------------------------------------
// Directly associated instances scan their linked installation
// -----------------------------------------------------------------------
/// The launcher state is a process-wide singleton; initialize it once and
/// reuse it so `State::get()` (used by the hash cache) resolves inside
/// these APIs. The state root is intentionally leaked (`.keep()`) because
/// the shared state outlives this function.
async fn global_state() -> Arc<State> {
if !State::initialized() {
let root = tempfile::TempDir::new().unwrap().keep();
let _ =
State::init_for_test(root.to_string_lossy().to_string()).await;
}
State::get().await.unwrap()
}
fn write_self_contained_version(minecraft: &Path, version_id: &str) {
let version_dir = minecraft.join("versions").join(version_id);
fs::create_dir_all(&version_dir).unwrap();
fs::write(
version_dir.join(format!("{version_id}.json")),
serde_json::to_vec(&serde_json::json!({
"id": version_id,
"mainClass": "net.minecraft.client.main.Main"
}))
.unwrap(),
)
.unwrap();
}
#[tokio::test]
async fn direct_link_refresh_scans_linked_dot_minecraft_content() {
let state = global_state().await;
let minecraft = tempfile::TempDir::new().unwrap();
write_self_contained_version(minecraft.path(), "1.12.2-linked");
fs::create_dir_all(minecraft.path().join("mods")).unwrap();
fs::write(
minecraft.path().join("mods/linked-mod.jar"),
b"linked mod bytes",
)
.unwrap();
let instance = crate::state::create_direct_link_instance(
crate::state::CreateDirectLinkInstance {
name: None,
launcher_type:
crate::api::pack::import::ImportLauncherType::Generic,
base_path: minecraft.path().to_path_buf(),
instance_folder: "versions/1.12.2-linked".to_string(),
instance_path: None,
game_dir_mode: None,
},
&state,
)
.await
.unwrap();
let files = sync_instance_content_files(&instance, &state)
.await
.unwrap();
assert!(
files
.iter()
.any(|file| file.relative_path == "mods/linked-mod.jar"),
"refresh must discover mods inside the linked `.minecraft`, got: {:?}",
files
.iter()
.map(|file| &file.relative_path)
.collect::<Vec<_>>(),
);
// A second refresh re-hashes the existing row. It must continue to
// use the linked external root rather than the Axolotl profile path.
let refreshed = sync_instance_content_files(&instance, &state)
.await
.unwrap();
assert!(
refreshed
.iter()
.any(|file| file.relative_path == "mods/linked-mod.jar")
);
}
#[tokio::test]
async fn pcl_isolated_direct_link_scans_version_isolated_content() {
let state = global_state().await;
let minecraft = tempfile::TempDir::new().unwrap();
write_self_contained_version(minecraft.path(), "1.12.2-pcl");
// Version isolation on: PCL resolves this version's gameDir to
// versions/<id>, so its mods folder lives beside the version JSON.
let version_dir = minecraft.path().join("versions/1.12.2-pcl");
fs::create_dir_all(version_dir.join("PCL")).unwrap();
fs::write(
version_dir.join("PCL/Setup.ini"),
"VersionArgumentIndieV2: true\n",
)
.unwrap();
fs::create_dir_all(version_dir.join("mods")).unwrap();
fs::write(
version_dir.join("mods/isolated-mod.jar"),
b"isolated mod bytes",
)
.unwrap();
let instance = crate::state::create_direct_link_instance(
crate::state::CreateDirectLinkInstance {
name: None,
launcher_type:
crate::api::pack::import::ImportLauncherType::PCL2,
base_path: minecraft.path().to_path_buf(),
instance_folder: "versions/1.12.2-pcl".to_string(),
instance_path: None,
game_dir_mode: None,
},
&state,
)
.await
.unwrap();
let files = sync_instance_content_files(&instance, &state)
.await
.unwrap();
assert!(
files
.iter()
.any(|file| file.relative_path == "mods/isolated-mod.jar"),
"refresh must resolve the PCL-isolated gameDir, got: {:?}",
files
.iter()
.map(|file| &file.relative_path)
.collect::<Vec<_>>(),
);
}
#[tokio::test]
async fn ordinary_instance_refresh_still_scans_its_profile_directory() {
let state = global_state().await;
let metadata = crate::api::instance::create(
format!("sync-normal {}", uuid::Uuid::new_v4()),
"1.20.1".to_string(),
crate::state::ModLoader::Vanilla,
None,
None,
crate::state::InstanceLink::Unmanaged,
None,
None,
)
.await
.unwrap();
let mods = state
.directories
.instances_dir()
.join(&metadata.instance.path)
.join("mods");
fs::create_dir_all(&mods).unwrap();
fs::write(mods.join("profile-mod.jar"), b"profile mod bytes").unwrap();
let files = sync_instance_content_files(&metadata.instance, &state)
.await
.unwrap();
assert!(
files
.iter()
.any(|file| file.relative_path == "mods/profile-mod.jar"),
"ordinary instances must keep scanning their profile directory, \
got: {:?}",
files
.iter()
.map(|file| &file.relative_path)
.collect::<Vec<_>>(),
);
}
/// Regression probe: an instance whose `game_dir_override` points to an
/// external `.minecraft` root must have its content scanned from that root,
/// not from the (empty) managed instance folder. This is the split-brain
/// the content page empty-state used to hit.
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn content_scan_uses_game_dir_override() {
crate::event::EventState::init().await.unwrap();
let root = tempfile::tempdir().unwrap().keep();
let state =
crate::State::init_for_test(root.to_string_lossy().to_string())
.await
.unwrap();
// Create an external .minecraft root with a mod, outside the managed
// profiles dir.
let mc_root = tempfile::tempdir().unwrap();
let mods_dir = mc_root.path().join("mods");
fs::create_dir_all(&mods_dir).unwrap();
fs::write(mods_dir.join("my-mod.jar"), "mod").unwrap();
let created = crate::api::instance::create(
"Override Instance".to_string(),
"1.20.1".to_string(),
crate::state::ModLoader::Vanilla,
None,
None,
crate::state::InstanceLink::Unmanaged,
None,
Some(mc_root.path().to_string_lossy().to_string()),
)
.await
.unwrap();
crate::state::instances::commands::set_instance_install_stage(
&created.instance.id,
crate::state::InstanceInstallStage::Installed,
&state.pool,
)
.await
.unwrap();
let instance =
crate::state::instances::adapters::sqlite::instance_rows::get_instance_by_id(
&created.instance.id,
&state.pool,
)
.await
.unwrap()
.unwrap();
assert_eq!(
state.directories.instance_game_dir(&instance),
mc_root.path(),
"instance_game_dir must resolve to the override root"
);
let direct =
filesystem::scan_content_files_from(mc_root.path(), &instance.path)
.unwrap();
assert_eq!(
direct.len(),
1,
"direct scan of override root should find the mod"
);
let files = sync_instance_content_files(&instance, &state)
.await
.unwrap();
assert_eq!(
files.len(),
1,
"expected the override-root mod to be scanned"
);
assert!(files[0].relative_path.ends_with("mods/my-mod.jar"));
}
#[test]
fn external_hash_or_size_change_invalidates_physical_identity() {
let now = Utc::now();
let file = InstanceFile {
id: "file".to_string(),
instance_id: "instance".to_string(),
relative_path: "mods/lithium.jar".to_string(),
file_name: "lithium.jar".to_string(),
enabled: true,
sha1: "official".to_string(),
size: 10,
missing: false,
added_at: now,
modified_at: now,
local_mod_data: None,
icon_path: None,
};
assert!(!physical_file_identity_changed(&file, "official", 10));
assert!(physical_file_identity_changed(&file, "external", 10));
assert!(physical_file_identity_changed(&file, "official", 11));
}
}
@@ -0,0 +1,421 @@
use super::create_direct_link_instance::create_direct_link_instance;
use crate::api::pack::import::direct_link::{
detect_direct_link_source, direct_link_group,
has_minecraft_version_manifest, resolve_direct_link,
};
use crate::event::{InstancePayloadType, emit::emit_instance};
use crate::launcher::ExternalGameDirMode;
use crate::state::instances::{
CreateDirectLinkInstance, EditInstance, adapters::sqlite::instance_rows,
};
use crate::state::{AppliedContentSetPatch, State};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalMinecraftRoot {
pub path: PathBuf,
#[serde(default = "default_external_root_mode")]
pub mode: ExternalGameDirMode,
}
fn default_external_root_mode() -> ExternalGameDirMode {
// Existing string-only Settings entries used version isolation exclusively.
ExternalGameDirMode::Isolated
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectLinkSyncReport {
pub imported: u32,
pub updated: u32,
pub removed: u32,
pub missing: u32,
pub errors: Vec<String>,
}
/// Reconciles configured external `.minecraft` roots with Axolotl's instance
/// records. The external filesystem is authoritative: new version folders are
/// associated, changed JSON metadata is refreshed, and records whose version
/// JSON disappeared are removed without touching any remaining files.
pub(crate) async fn sync_direct_link_instances(
roots: Vec<ExternalMinecraftRoot>,
state: &State,
) -> crate::Result<DirectLinkSyncReport> {
let mut report = DirectLinkSyncReport::default();
let mut canonical_roots = Vec::new();
for root in &roots {
match crate::util::io::canonicalize(&root.path) {
Ok(path) if path.is_dir() => {
canonical_roots.push((path, root.mode))
}
Ok(_) => {
report.missing += 1;
report.errors.push(format!(
"{} is not a directory",
root.path.display()
));
}
Err(error) => {
if error.kind() == std::io::ErrorKind::NotFound {
report.missing += 1;
}
report
.errors
.push(format!("{}: {error}", root.path.display()));
}
}
}
canonical_roots.sort_by(|left, right| left.0.cmp(&right.0));
canonical_roots.dedup_by(|left, right| left.0 == right.0);
let existing = crate::state::list_instances(&state.pool)
.await?
.into_iter()
.collect::<Vec<_>>();
let mut seen_json = Vec::<PathBuf>::new();
for (root, mode) in &canonical_roots {
let versions = root.join("versions");
let entries = match std::fs::read_dir(&versions) {
Ok(entries) => entries,
Err(error) => {
report
.errors
.push(format!("{}: {error}", versions.display()));
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
report
.errors
.push(format!("{}: {error}", versions.display()));
continue;
}
};
let folder = entry.path();
if !folder.is_dir() {
continue;
}
// A `versions` directory can also contain PCL bookkeeping entries
// left by an interrupted install. They have no version manifest,
// so they are not Minecraft instances and should not surface a
// warning on every root synchronization.
if !has_minecraft_version_manifest(&folder) {
continue;
}
let folder_name = entry.file_name().to_string_lossy().to_string();
let instance_folder = Path::new("versions").join(&folder_name);
let source = detect_direct_link_source(root, &folder);
let resolved = match resolve_direct_link(
source.launcher,
source.launcher_root.clone(),
instance_folder.to_string_lossy().to_string(),
Some(folder.to_string_lossy().to_string()),
)
.await
{
Ok(resolved) => resolved,
Err(error) => {
report
.errors
.push(format!("{}: {error}", folder.display()));
continue;
}
};
seen_json.push(resolved.version_json.clone());
let existing_instance = existing.iter().find(|metadata| {
metadata
.instance
.linked_version_json_path
.as_deref()
.is_some_and(|path| {
Path::new(path) == resolved.version_json
})
|| metadata
.instance
.game_dir_override
.as_deref()
.is_some_and(|path| Path::new(path) == folder)
});
if let Some(metadata) = existing_instance {
let instance = &metadata.instance;
let fields_changed = instance.linked_version_id.as_deref()
!= Some(resolved.version_id.as_str())
|| instance.linked_launcher.as_deref()
!= Some(resolved.launcher_key())
|| instance.linked_launcher_root.as_deref()
!= Some(
resolved.launcher_root.to_string_lossy().as_ref(),
)
|| instance.linked_dot_minecraft.as_deref()
!= Some(
resolved.dot_minecraft.to_string_lossy().as_ref(),
)
|| instance.linked_game_dir_mode.as_deref()
!= Some(mode.key());
let content_changed = metadata.applied_content_set.game_version
!= resolved.game_version
|| metadata.applied_content_set.loader != resolved.loader;
let groups = direct_link_group(&resolved.dot_minecraft)
.into_iter()
.collect::<Vec<_>>();
let groups_changed = metadata.groups != groups;
if fields_changed || content_changed || groups_changed {
if content_changed {
crate::state::edit_instance(
&instance.id,
EditInstance {
content_set_patch: Some(
AppliedContentSetPatch {
game_version: Some(
resolved.game_version.clone(),
),
loader: Some(resolved.loader),
..AppliedContentSetPatch::default()
},
),
..EditInstance::default()
},
&state.pool,
)
.await?;
}
let mut tx = state.pool.begin().await?;
instance_rows::set_direct_link_fields(
&instance.id,
&instance_rows::DirectLinkFields {
launcher: Some(resolved.launcher_key().to_string()),
launcher_root: Some(
resolved
.launcher_root
.to_string_lossy()
.to_string(),
),
dot_minecraft: Some(
resolved
.dot_minecraft
.to_string_lossy()
.to_string(),
),
version_id: Some(resolved.version_id.clone()),
version_json_path: Some(
resolved
.version_json
.to_string_lossy()
.to_string(),
),
game_dir_mode: Some(mode.key().to_string()),
},
&mut tx,
)
.await?;
instance_rows::replace_instance_groups(
&instance.id,
&groups,
&mut tx,
)
.await?;
tx.commit().await?;
let _ = emit_instance(
&instance.id,
InstancePayloadType::Edited,
)
.await;
report.updated += 1;
}
} else {
let instance = match create_direct_link_instance(
CreateDirectLinkInstance {
name: Some(folder_name),
launcher_type: source.launcher,
base_path: source.launcher_root,
instance_folder: instance_folder
.to_string_lossy()
.to_string(),
instance_path: Some(
folder.to_string_lossy().to_string(),
),
game_dir_mode: Some(*mode),
},
state,
)
.await
{
Ok(instance) => instance,
Err(error) => {
report
.errors
.push(format!("{}: {error}", folder.display()));
continue;
}
};
let _ =
emit_instance(&instance.id, InstancePayloadType::Created)
.await;
report.imported += 1;
}
}
}
// Ordinary instances created with a version-isolated game-dir override
// are also associated with a configured root. If that root is removed
// from Settings before the next scan promotes the record to a direct
// link, drop only the Axolotl record here as well.
for metadata in &existing {
if metadata.instance.linked_dot_minecraft.is_some() {
continue;
}
let Some(game_dir_override) =
metadata.instance.game_dir_override.as_deref()
else {
continue;
};
let Some(root) = version_isolated_root(game_dir_override) else {
continue;
};
if configured_root_matches(&root, &canonical_roots, &roots) {
continue;
}
instance_rows::delete_instance_by_id(
&metadata.instance.id,
&state.pool,
)
.await?;
let _ =
emit_instance(&metadata.instance.id, InstancePayloadType::Removed)
.await;
report.removed += 1;
}
for metadata in existing {
let Some(json_path) =
metadata.instance.linked_version_json_path.as_deref()
else {
continue;
};
let Some(root) = metadata.instance.linked_dot_minecraft.as_deref()
else {
continue;
};
if !configured_root_matches(Path::new(root), &canonical_roots, &roots) {
// Configured roots are authoritative. Removing a root from Settings
// only drops Axolotl's association; the external files remain intact.
instance_rows::delete_instance_by_id(
&metadata.instance.id,
&state.pool,
)
.await?;
let _ = emit_instance(
&metadata.instance.id,
InstancePayloadType::Removed,
)
.await;
report.removed += 1;
continue;
}
let json_path = PathBuf::from(json_path);
if !json_path.exists()
&& !seen_json.iter().any(|path| path == &json_path)
{
// External deletion is authoritative, but there is nothing left
// to delete on disk. Only remove the stale Axolotl record.
instance_rows::delete_instance_by_id(
&metadata.instance.id,
&state.pool,
)
.await?;
let _ = emit_instance(
&metadata.instance.id,
InstancePayloadType::Removed,
)
.await;
report.removed += 1;
}
}
Ok(report)
}
fn version_isolated_root(path: &str) -> Option<PathBuf> {
let version_dir = Path::new(path);
if version_dir
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
!= Some("versions")
{
return None;
}
version_dir.parent()?.parent().map(Path::to_path_buf)
}
/// A root that remains in Settings must retain its associated records even
/// when it cannot currently be opened (for example, a disconnected drive or
/// a transient permission failure). Only removing the root from Settings may
/// drop all of its associations.
fn configured_root_matches(
root: &Path,
canonical_roots: &[(PathBuf, ExternalGameDirMode)],
configured_roots: &[ExternalMinecraftRoot],
) -> bool {
canonical_roots
.iter()
.any(|(candidate, _)| candidate == root)
|| configured_roots
.iter()
.any(|candidate| paths_match(&candidate.path, root))
}
fn paths_match(left: &Path, right: &Path) -> bool {
let left = left.components().collect::<PathBuf>();
let right = right.components().collect::<PathBuf>();
#[cfg(target_os = "windows")]
{
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
}
#[cfg(not(target_os = "windows"))]
{
left == right
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn configured_but_unavailable_root_keeps_its_association() {
let root = PathBuf::from("minecraft-root");
let equivalent = PathBuf::from("minecraft-root").join(".");
assert!(configured_root_matches(
&equivalent,
&[],
&[ExternalMinecraftRoot {
path: root,
mode: ExternalGameDirMode::Isolated,
}],
));
}
#[test]
fn removed_root_does_not_keep_its_association() {
assert!(!configured_root_matches(
Path::new("minecraft-root"),
&[],
&[ExternalMinecraftRoot {
path: PathBuf::from("other-root"),
mode: ExternalGameDirMode::Isolated,
}],
));
}
}