feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,530 @@
|
||||
use crate::state::{
|
||||
CacheBehaviour, ContentFile, ContentItem, ContentItemUpdate,
|
||||
ContentOwnershipKind, ContentProvider, ContentSet, ContentUpdatePlan,
|
||||
ContentUpdatePlanAction, ContentUpdateResolution,
|
||||
ContentUpdateResolutionChoice, ContentUpdateScope, Dependency,
|
||||
InstanceContentSnapshot, InstanceInstallCandidate, InstanceInstallTarget,
|
||||
LinkedModpackInfo, ProjectType, State,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static CONTENT_UPDATE_PLANS: LazyLock<DashMap<String, ContentUpdatePlan>> =
|
||||
LazyLock::new(DashMap::new);
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn sync_content_files(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Vec<crate::state::instances::InstanceFile>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::sync_content_files(instance_id, &state).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn list_content_sets(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Vec<ContentSet>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::list_content_sets(instance_id, &state.pool).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_projects(
|
||||
instance_id: &str,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
) -> crate::Result<DashMap<String, ContentFile>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::get_content_projects(
|
||||
instance_id,
|
||||
None,
|
||||
cache_behaviour,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_installed_project_ids(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::get_installed_project_ids_for_instance(
|
||||
instance_id,
|
||||
None,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_install_candidates(
|
||||
project_id: &str,
|
||||
project_type: ProjectType,
|
||||
targets: Vec<InstanceInstallTarget>,
|
||||
) -> crate::Result<Vec<InstanceInstallCandidate>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::get_instance_install_candidates(
|
||||
project_id,
|
||||
project_type,
|
||||
&targets,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_content_items(
|
||||
instance_id: &str,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::list_content(instance_id, None, cache_behaviour, &state).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_content_items_by_paths(
|
||||
instance_id: &str,
|
||||
paths: Vec<String>,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::list_content_by_paths(
|
||||
instance_id,
|
||||
&paths,
|
||||
cache_behaviour,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_content_snapshot(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<InstanceContentSnapshot> {
|
||||
let state = State::get().await?;
|
||||
crate::state::get_content_snapshot(instance_id, false, &state).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn refresh_content(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<InstanceContentSnapshot> {
|
||||
let state = State::get().await?;
|
||||
require_installed_instance(instance_id, &state).await?;
|
||||
crate::state::get_content_snapshot(instance_id, true, &state).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn plan_content_updates(
|
||||
instance_id: &str,
|
||||
scope: ContentUpdateScope,
|
||||
target: Option<&str>,
|
||||
) -> crate::Result<ContentUpdatePlan> {
|
||||
let state = State::get().await?;
|
||||
let snapshot =
|
||||
crate::state::get_content_snapshot(instance_id, true, &state).await?;
|
||||
let mut actions = Vec::new();
|
||||
|
||||
match scope {
|
||||
ContentUpdateScope::UserAdded | ContentUpdateScope::Item => {
|
||||
if scope == ContentUpdateScope::Item && target.is_none() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"An item update plan requires a stable content ID"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
for item in &snapshot.items {
|
||||
let content_id = item
|
||||
.entry_id
|
||||
.as_deref()
|
||||
.or(item.member_id.as_deref())
|
||||
.or(item.file_id.as_deref());
|
||||
let selected =
|
||||
update_scope_selects_item(scope, target, item, content_id);
|
||||
if !selected {
|
||||
continue;
|
||||
}
|
||||
let Some(content) = item.content.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(update) = content.update.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(content_id) = content_id else {
|
||||
continue;
|
||||
};
|
||||
actions.push(update_action(
|
||||
content_id.to_string(),
|
||||
Some(item.expected_relative_path.clone()),
|
||||
item.ownership_kind,
|
||||
update,
|
||||
));
|
||||
}
|
||||
}
|
||||
ContentUpdateScope::Pack => {
|
||||
let pack = snapshot.pack.as_ref().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"This instance is not linked to a managed pack".to_string(),
|
||||
)
|
||||
})?;
|
||||
if !pack.reconciled {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Pack membership is not calibrated yet; refresh while online before updating the pack"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if let Some(target_release_id) = target {
|
||||
let provider = pack.provider.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"This pack has no managed provider".to_string(),
|
||||
)
|
||||
})?;
|
||||
actions.push(ContentUpdatePlanAction {
|
||||
content_id: "pack".to_string(),
|
||||
relative_path: None,
|
||||
ownership_kind: ContentOwnershipKind::PackManaged,
|
||||
provider,
|
||||
current_release_id: pack.version_id.clone(),
|
||||
target_release_id: target_release_id.to_string(),
|
||||
});
|
||||
} else if let Some(update) = pack
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.update.as_ref())
|
||||
{
|
||||
actions.push(update_action(
|
||||
"pack".to_string(),
|
||||
None,
|
||||
ContentOwnershipKind::PackManaged,
|
||||
update,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let plan = ContentUpdatePlan {
|
||||
id: format!("content-update-plan:{}", uuid::Uuid::new_v4()),
|
||||
instance_id: instance_id.to_string(),
|
||||
revision: snapshot.revision,
|
||||
scope,
|
||||
actions,
|
||||
warnings: snapshot
|
||||
.warnings
|
||||
.into_iter()
|
||||
.map(|warning| warning.message)
|
||||
.collect(),
|
||||
};
|
||||
CONTENT_UPDATE_PLANS.insert(plan.id.clone(), plan.clone());
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn apply_content_update_plan(
|
||||
plan_id: &str,
|
||||
resolutions: Vec<ContentUpdateResolution>,
|
||||
) -> crate::Result<InstanceContentSnapshot> {
|
||||
let plan = CONTENT_UPDATE_PLANS
|
||||
.get(plan_id)
|
||||
.map(|entry| entry.clone())
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The content update plan has expired".to_string(),
|
||||
)
|
||||
})?;
|
||||
let state = State::get().await?;
|
||||
let current_revision = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
|
||||
&plan.instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance has no applied content set".to_string(),
|
||||
)
|
||||
})?
|
||||
.revision;
|
||||
if let Err(error) =
|
||||
ensure_update_plan_revision(plan.revision, current_revision)
|
||||
{
|
||||
CONTENT_UPDATE_PLANS.remove(plan_id);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
for resolution in resolutions {
|
||||
if resolution.choice
|
||||
== ContentUpdateResolutionChoice::RestorePackDefault
|
||||
{
|
||||
super::projects::restore_pack_member_default(
|
||||
&plan.instance_id,
|
||||
&resolution.content_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if plan.scope == ContentUpdateScope::Pack {
|
||||
if let Some(action) = plan.actions.first() {
|
||||
match action.provider {
|
||||
ContentProvider::Modrinth => {
|
||||
super::projects::update_managed_modrinth_version(
|
||||
&plan.instance_id,
|
||||
&action.target_release_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ContentProvider::CurseForge => {
|
||||
let file_id = action
|
||||
.target_release_id
|
||||
.parse::<u32>()
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The planned CurseForge file ID is invalid"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
crate::install::runner::update_managed_curseforge_modpack(
|
||||
plan.instance_id.clone(),
|
||||
file_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ContentProvider::McArchive => {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"MCArchive does not support automatic pack updates"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
ContentProvider::Local => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for action in &plan.actions {
|
||||
super::projects::switch_content_entry_version(
|
||||
&plan.instance_id,
|
||||
&action.content_id,
|
||||
&action.target_release_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
CONTENT_UPDATE_PLANS.remove(plan_id);
|
||||
crate::state::get_content_snapshot(&plan.instance_id, false, &state).await
|
||||
}
|
||||
|
||||
fn update_action(
|
||||
content_id: String,
|
||||
relative_path: Option<String>,
|
||||
ownership_kind: ContentOwnershipKind,
|
||||
update: &ContentItemUpdate,
|
||||
) -> ContentUpdatePlanAction {
|
||||
match update {
|
||||
ContentItemUpdate::Modrinth {
|
||||
current_version_id,
|
||||
target_version_id,
|
||||
..
|
||||
} => ContentUpdatePlanAction {
|
||||
content_id,
|
||||
relative_path,
|
||||
ownership_kind,
|
||||
provider: ContentProvider::Modrinth,
|
||||
current_release_id: Some(current_version_id.to_string()),
|
||||
target_release_id: target_version_id.to_string(),
|
||||
},
|
||||
ContentItemUpdate::CurseForge {
|
||||
current_file_id,
|
||||
target_file_id,
|
||||
..
|
||||
} => ContentUpdatePlanAction {
|
||||
content_id,
|
||||
relative_path,
|
||||
ownership_kind,
|
||||
provider: ContentProvider::CurseForge,
|
||||
current_release_id: Some(current_file_id.get().to_string()),
|
||||
target_release_id: target_file_id.get().to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn update_scope_selects_item(
|
||||
scope: ContentUpdateScope,
|
||||
target: Option<&str>,
|
||||
item: &crate::state::InstanceContentSnapshotItem,
|
||||
content_id: Option<&str>,
|
||||
) -> bool {
|
||||
match scope {
|
||||
ContentUpdateScope::UserAdded => {
|
||||
item.ownership_kind == ContentOwnershipKind::UserAdded
|
||||
}
|
||||
ContentUpdateScope::Item => content_id == target,
|
||||
ContentUpdateScope::Pack => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_update_plan_revision(
|
||||
planned_revision: u64,
|
||||
current_revision: u64,
|
||||
) -> crate::Result<()> {
|
||||
if planned_revision == current_revision {
|
||||
return Ok(());
|
||||
}
|
||||
Err(crate::ErrorKind::InputError(format!(
|
||||
"The content update plan is stale (planned revision {planned_revision}, current revision {current_revision})"
|
||||
))
|
||||
.into())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_linked_modpack_content(
|
||||
instance_id: &str,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::list_linked_modpack_content(
|
||||
instance_id,
|
||||
None,
|
||||
cache_behaviour,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_dependencies_as_content_items(
|
||||
dependencies: Vec<Dependency>,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::dependencies_to_content_items(
|
||||
&dependencies,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_linked_modpack_info(
|
||||
instance_id: &str,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
) -> crate::Result<Option<LinkedModpackInfo>> {
|
||||
let state = State::get().await?;
|
||||
require_installed_instance(instance_id, &state).await?;
|
||||
crate::state::get_linked_modpack_info(
|
||||
instance_id,
|
||||
None,
|
||||
cache_behaviour,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn require_installed_instance(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let metadata = crate::state::instances::commands::get_instance_metadata(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::UnmanagedInstanceError(instance_id.to_string())
|
||||
})?;
|
||||
let stage = metadata.instance.install_stage;
|
||||
if stage != crate::state::InstanceInstallStage::Installed {
|
||||
return Err(crate::ErrorKind::InstanceNotReady {
|
||||
instance_id: instance_id.to_string(),
|
||||
stage: stage.as_str().to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::instances::{
|
||||
ContentItemCapabilities, InstanceContentSnapshotItem,
|
||||
PackMemberMaterializationState, PackMemberOverrideKind,
|
||||
};
|
||||
|
||||
fn snapshot_item(
|
||||
ownership_kind: ContentOwnershipKind,
|
||||
entry_id: Option<&str>,
|
||||
member_id: Option<&str>,
|
||||
file_id: Option<&str>,
|
||||
) -> InstanceContentSnapshotItem {
|
||||
InstanceContentSnapshotItem {
|
||||
file_id: file_id.map(str::to_string),
|
||||
entry_id: entry_id.map(str::to_string),
|
||||
member_id: member_id.map(str::to_string),
|
||||
ownership_kind,
|
||||
materialization_state: PackMemberMaterializationState::Present,
|
||||
override_kind: PackMemberOverrideKind::None,
|
||||
expected_relative_path: "mods/test.jar".to_string(),
|
||||
required: true,
|
||||
project_type: ProjectType::Mod,
|
||||
provider: None,
|
||||
provider_project_id: None,
|
||||
provider_release_id: None,
|
||||
content: None,
|
||||
capabilities: ContentItemCapabilities::default(),
|
||||
dependency: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_added_update_scope_excludes_pack_and_discovered_content() {
|
||||
let user_added = snapshot_item(
|
||||
ContentOwnershipKind::UserAdded,
|
||||
Some("user-entry"),
|
||||
None,
|
||||
Some("user-file"),
|
||||
);
|
||||
let pack_managed = snapshot_item(
|
||||
ContentOwnershipKind::PackManaged,
|
||||
Some("pack-entry"),
|
||||
Some("pack-member"),
|
||||
Some("pack-file"),
|
||||
);
|
||||
let discovered = snapshot_item(
|
||||
ContentOwnershipKind::LocalDiscovered,
|
||||
None,
|
||||
None,
|
||||
Some("discovered-file"),
|
||||
);
|
||||
|
||||
assert!(update_scope_selects_item(
|
||||
ContentUpdateScope::UserAdded,
|
||||
None,
|
||||
&user_added,
|
||||
user_added.entry_id.as_deref(),
|
||||
));
|
||||
assert!(!update_scope_selects_item(
|
||||
ContentUpdateScope::UserAdded,
|
||||
None,
|
||||
&pack_managed,
|
||||
pack_managed.entry_id.as_deref(),
|
||||
));
|
||||
assert!(!update_scope_selects_item(
|
||||
ContentUpdateScope::UserAdded,
|
||||
None,
|
||||
&discovered,
|
||||
discovered.file_id.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_plan_revision_rejects_stale_plans() {
|
||||
assert!(ensure_update_plan_revision(7, 7).is_ok());
|
||||
let error = ensure_update_plan_revision(7, 8).unwrap_err();
|
||||
assert!(error.to_string().contains("planned revision 7"));
|
||||
assert!(error.to_string().contains("current revision 8"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,722 @@
|
||||
use super::content::get_projects;
|
||||
use super::get::get;
|
||||
use super::paths::get_full_path;
|
||||
use crate::api::content_search::original_content_relative_path;
|
||||
use crate::event::LoadingBarType;
|
||||
use crate::event::emit::{emit_loading, init_loading};
|
||||
use crate::pack::install_from::{
|
||||
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
|
||||
};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, ContentProviderRef, InstanceMetadata,
|
||||
ModLoader, ModrinthVersionId, SideType, State,
|
||||
};
|
||||
use crate::util::io::{self, IOError};
|
||||
use async_zip::tokio::write::ZipFileWriter;
|
||||
use async_zip::{Compression, DeflateOption, ZipEntryBuilder};
|
||||
use futures::io::AsyncWriteExt;
|
||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
const METADATA_PROGRESS_WEIGHT: f64 = 15.0;
|
||||
const WRITE_PROGRESS_WEIGHT: f64 = 80.0;
|
||||
/// Minimum bar movement (in progress points) before another loading event is
|
||||
/// emitted, so small packs do not spam the UI with per-chunk updates.
|
||||
const PROGRESS_EMIT_THRESHOLD: f64 = 0.1;
|
||||
/// Hard floor on how many copy chunks may pass between loading events, so very
|
||||
/// large files still move the bar while they are being packed.
|
||||
const PROGRESS_EMIT_CHUNKS: u32 = 64;
|
||||
/// Deflate is only applied to compressible text/config files; a low level
|
||||
/// keeps packing fast while still shrinking those files.
|
||||
const DEFLATE_OPTION: DeflateOption = DeflateOption::Other(1);
|
||||
const COPY_BUFFER_SIZE: usize = 256 * 1024;
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn export_mrpack(
|
||||
instance_id: &str,
|
||||
export_path: PathBuf,
|
||||
included_export_candidates: Vec<String>,
|
||||
version_id: Option<String>,
|
||||
description: Option<String>,
|
||||
_name: Option<String>,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let _permit: tokio::sync::SemaphorePermit =
|
||||
state.io_semaphore.0.acquire().await?;
|
||||
let metadata = get(instance_id).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to export a nonexistent instance {instance_id}!"
|
||||
))
|
||||
})?;
|
||||
// Directly associated instances own no files: everything lives in the
|
||||
// linked launcher's `.minecraft`, so there is nothing exportable here.
|
||||
if metadata.instance.is_direct_linked() {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"\"{}\" is directly associated with an external launcher; its \
|
||||
files are managed by that launcher and cannot be exported",
|
||||
metadata.instance.name
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let included_export_candidates = included_export_candidates
|
||||
.into_iter()
|
||||
.map(|candidate| candidate.replace('\\', "/"))
|
||||
.filter(|x| {
|
||||
if let Some(f) = PathBuf::from(x).file_name()
|
||||
&& f.to_string_lossy().starts_with(".DS_Store")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let instance_base_path = get_full_path(instance_id).await?;
|
||||
let mut file = File::create(&export_path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &export_path))?;
|
||||
let mut writer = ZipFileWriter::with_tokio(&mut file);
|
||||
let version_id = version_id.unwrap_or("1.0.0".to_string());
|
||||
let loading_bar = init_loading(
|
||||
LoadingBarType::ZipExtract {
|
||||
instance_id: metadata.instance.id.clone(),
|
||||
instance_name: metadata.instance.name.clone(),
|
||||
},
|
||||
100.0,
|
||||
"Exporting instance to .mrpack",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (mut progress, mut packfile) = {
|
||||
let mut metadata_progress = 0.0;
|
||||
let mut on_metadata_progress = |target: f64| {
|
||||
let target = METADATA_PROGRESS_WEIGHT * target.clamp(0.0, 1.0);
|
||||
let delta = target - metadata_progress;
|
||||
metadata_progress = target;
|
||||
let _ = emit_loading(
|
||||
&loading_bar,
|
||||
delta,
|
||||
Some("Preparing modpack metadata"),
|
||||
);
|
||||
};
|
||||
let packfile = create_mrpack_json_inner(
|
||||
&metadata,
|
||||
version_id,
|
||||
description,
|
||||
&mut on_metadata_progress,
|
||||
)
|
||||
.await?;
|
||||
drop(on_metadata_progress);
|
||||
(metadata_progress, packfile)
|
||||
};
|
||||
packfile.files.retain(|f| {
|
||||
is_export_candidate_included(
|
||||
f.path.as_str(),
|
||||
&included_export_candidates,
|
||||
)
|
||||
});
|
||||
strip_localized_pack_file_paths(&mut packfile.files);
|
||||
|
||||
let mut path_list = Vec::new();
|
||||
add_all_recursive_folder_paths(&instance_base_path, &mut path_list).await?;
|
||||
let disk_paths = path_list
|
||||
.iter()
|
||||
.filter(|path| path.is_file())
|
||||
.filter_map(|path| {
|
||||
pack_get_relative_path(&instance_base_path, path)
|
||||
.ok()
|
||||
.map(|relative_path| relative_path.as_str().to_string())
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
let mut write_list: Vec<(PathBuf, String)> = Vec::new();
|
||||
let mut write_total_bytes: u64 = 0;
|
||||
for path in &path_list {
|
||||
let Ok(relative_path) =
|
||||
pack_get_relative_path(&instance_base_path, path)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let exported_path =
|
||||
original_content_relative_path(relative_path.as_str());
|
||||
let Ok(metadata) = path.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if !metadata.is_file()
|
||||
|| packfile
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| f.path.as_str() == exported_path)
|
||||
|| !is_export_candidate_included(
|
||||
relative_path.as_str(),
|
||||
&included_export_candidates,
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
write_total_bytes += metadata.len();
|
||||
write_list.push((path.clone(), relative_path.as_str().to_string()));
|
||||
}
|
||||
let mut written_override_paths = HashSet::new();
|
||||
let mut buffer = vec![0u8; COPY_BUFFER_SIZE];
|
||||
let mut bytes_written = 0u64;
|
||||
let mut write_progress = 0.0_f64;
|
||||
for (path, relative_path) in write_list {
|
||||
let exported_path =
|
||||
original_content_relative_path(relative_path.as_str());
|
||||
let entry_path = if exported_path != relative_path.as_str()
|
||||
&& !disk_paths.contains(&exported_path)
|
||||
&& written_override_paths.insert(exported_path.clone())
|
||||
{
|
||||
exported_path
|
||||
} else {
|
||||
relative_path.as_str().to_string()
|
||||
};
|
||||
let compression = if is_already_compressed(&entry_path) {
|
||||
Compression::Stored
|
||||
} else {
|
||||
Compression::Deflate
|
||||
};
|
||||
let builder = ZipEntryBuilder::new(
|
||||
format!("overrides/{entry_path}").into(),
|
||||
compression,
|
||||
)
|
||||
.deflate_option(DEFLATE_OPTION);
|
||||
let mut file = File::open(&path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &path))?;
|
||||
let mut entry = writer.write_entry_stream(builder).await?;
|
||||
let mut chunks_since_emit = 0u32;
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &path))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
entry
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &path))?;
|
||||
bytes_written += read as u64;
|
||||
chunks_since_emit += 1;
|
||||
let target = WRITE_PROGRESS_WEIGHT
|
||||
* (bytes_written as f64 / write_total_bytes.max(1) as f64)
|
||||
.min(1.0);
|
||||
let delta = target - write_progress;
|
||||
if delta >= PROGRESS_EMIT_THRESHOLD
|
||||
|| chunks_since_emit >= PROGRESS_EMIT_CHUNKS
|
||||
{
|
||||
write_progress = target;
|
||||
progress += delta;
|
||||
let _ = emit_loading(
|
||||
&loading_bar,
|
||||
delta,
|
||||
Some(relative_path.as_str()),
|
||||
);
|
||||
chunks_since_emit = 0;
|
||||
}
|
||||
}
|
||||
entry.close().await?;
|
||||
let target = WRITE_PROGRESS_WEIGHT
|
||||
* (bytes_written as f64 / write_total_bytes.max(1) as f64).min(1.0);
|
||||
let delta = target - write_progress;
|
||||
if delta > 0.0 {
|
||||
write_progress = target;
|
||||
progress += delta;
|
||||
let _ =
|
||||
emit_loading(&loading_bar, delta, Some(relative_path.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
let data = serde_json::to_vec_pretty(&packfile)?;
|
||||
let builder = ZipEntryBuilder::new(
|
||||
"modrinth.index.json".to_string().into(),
|
||||
Compression::Deflate,
|
||||
);
|
||||
writer.write_entry_whole(builder, &data).await?;
|
||||
writer.close().await?;
|
||||
|
||||
let _ =
|
||||
emit_loading(&loading_bar, 100.0 - progress, Some("Finalizing export"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files in these formats are already compressed, so re-compressing them
|
||||
/// with Deflate wastes CPU for little size gain; store them as-is instead.
|
||||
fn is_already_compressed(path: &str) -> bool {
|
||||
let Some(extension) = path.rsplit('.').next() else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
extension.to_ascii_lowercase().as_str(),
|
||||
"7z" | "aac"
|
||||
| "apk"
|
||||
| "avif"
|
||||
| "bz2"
|
||||
| "flac"
|
||||
| "gif"
|
||||
| "gz"
|
||||
| "heic"
|
||||
| "jar"
|
||||
| "jpeg"
|
||||
| "jpg"
|
||||
| "lz4"
|
||||
| "lzma"
|
||||
| "m4a"
|
||||
| "mkv"
|
||||
| "mov"
|
||||
| "mp3"
|
||||
| "mp4"
|
||||
| "ogg"
|
||||
| "oga"
|
||||
| "opus"
|
||||
| "png"
|
||||
| "rar"
|
||||
| "webm"
|
||||
| "webp"
|
||||
| "woff"
|
||||
| "woff2"
|
||||
| "xz"
|
||||
| "zip"
|
||||
| "zst"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_export_candidate_included(
|
||||
path: &str,
|
||||
included_export_candidates: &[String],
|
||||
) -> bool {
|
||||
included_export_candidates.iter().any(|candidate| {
|
||||
path == candidate
|
||||
|| path
|
||||
.strip_prefix(candidate)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewrites `[中文名]`-prefixed install paths back to their original names so
|
||||
/// exported packs stay free of localized file names. Entries whose stripped
|
||||
/// path would collide with another entry keep their on-disk name.
|
||||
fn strip_localized_pack_file_paths(files: &mut [PackFile]) {
|
||||
let mut used = files
|
||||
.iter()
|
||||
.map(|file| file.path.as_str().to_string())
|
||||
.collect::<HashSet<_>>();
|
||||
for file in files {
|
||||
let stripped = original_content_relative_path(file.path.as_str());
|
||||
if stripped == file.path.as_str() || used.contains(&stripped) {
|
||||
continue;
|
||||
}
|
||||
let Ok(path) = SafeRelativeUtf8UnixPathBuf::try_from(stripped.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
used.insert(stripped);
|
||||
file.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_pack_export_candidates(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
|
||||
let mut path_list = Vec::new();
|
||||
let instance_base_dir = get_full_path(instance_id).await?;
|
||||
let mut read_dir = io::read_dir(&instance_base_dir).await?;
|
||||
while let Some(entry) = read_dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
|
||||
{
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let mut read_dir = io::read_dir(&path).await?;
|
||||
while let Some(entry) = read_dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
|
||||
{
|
||||
path_list.push(pack_get_relative_path(
|
||||
&instance_base_dir,
|
||||
&entry.path(),
|
||||
)?);
|
||||
}
|
||||
} else {
|
||||
path_list.push(pack_get_relative_path(&instance_base_dir, &path)?);
|
||||
}
|
||||
}
|
||||
Ok(path_list)
|
||||
}
|
||||
|
||||
fn pack_get_relative_path(
|
||||
instance_path: &PathBuf,
|
||||
path: &PathBuf,
|
||||
) -> crate::Result<SafeRelativeUtf8UnixPathBuf> {
|
||||
Ok(SafeRelativeUtf8UnixPathBuf::try_from(
|
||||
path.strip_prefix(instance_path)
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"Path {path:?} does not correspond to an instance"
|
||||
))
|
||||
})?
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The `.mrpack` specification stores archive paths as Unix-style relative
|
||||
/// paths, even when the source instance is on Windows.
|
||||
fn mrpack_relative_path(
|
||||
relative_path: &str,
|
||||
) -> crate::Result<SafeRelativeUtf8UnixPathBuf> {
|
||||
Ok(SafeRelativeUtf8UnixPathBuf::try_from(
|
||||
relative_path.replace('\\', "/"),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn create_mrpack_json(
|
||||
metadata: &InstanceMetadata,
|
||||
version_id: String,
|
||||
description: Option<String>,
|
||||
) -> crate::Result<PackFormat> {
|
||||
create_mrpack_json_inner(metadata, version_id, description, &mut |_| {})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn create_mrpack_json_inner(
|
||||
metadata: &InstanceMetadata,
|
||||
version_id: String,
|
||||
description: Option<String>,
|
||||
on_progress: &mut impl FnMut(f64),
|
||||
) -> crate::Result<PackFormat> {
|
||||
let mut dependencies = HashMap::new();
|
||||
match (
|
||||
metadata.applied_content_set.loader,
|
||||
metadata.applied_content_set.loader_version.clone(),
|
||||
) {
|
||||
(ModLoader::Forge, Some(v)) => {
|
||||
dependencies.insert(PackDependency::Forge, v)
|
||||
}
|
||||
(ModLoader::NeoForge, Some(v)) => {
|
||||
dependencies.insert(PackDependency::NeoForge, v)
|
||||
}
|
||||
(ModLoader::Fabric, Some(v)) => {
|
||||
dependencies.insert(PackDependency::FabricLoader, v)
|
||||
}
|
||||
(ModLoader::Quilt, Some(v)) => {
|
||||
dependencies.insert(PackDependency::QuiltLoader, v)
|
||||
}
|
||||
(ModLoader::Babric, _) => {
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
"Babric instances cannot be exported to mrpack, as the format has no Babric dependency type".to_string(),
|
||||
).into())
|
||||
}
|
||||
(ModLoader::Vanilla, _) => None,
|
||||
(ModLoader::OptiFine, _) => {
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
"OptiFine instances cannot be exported to mrpack, as the format has no OptiFine dependency type".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
_ => {
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
"Loader version mismatch".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
dependencies.insert(
|
||||
PackDependency::Minecraft,
|
||||
metadata.applied_content_set.game_version.clone(),
|
||||
);
|
||||
|
||||
let state = State::get().await?;
|
||||
let projects = get_projects(
|
||||
&metadata.instance.id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
on_progress(0.2);
|
||||
let instance_path = get_full_path(&metadata.instance.id).await?;
|
||||
let mut modrinth_version_ids = projects
|
||||
.iter()
|
||||
.flat_map(|(_, file)| file.provider_refs.iter())
|
||||
.filter_map(|reference| match reference {
|
||||
ContentProviderRef::Modrinth {
|
||||
version_id: Some(version_id),
|
||||
..
|
||||
} => Some(version_id.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
for file in projects.iter().map(|(_, file)| file) {
|
||||
if let Some(metadata) = &file.modrinth {
|
||||
modrinth_version_ids.insert(metadata.version_id.to_string());
|
||||
}
|
||||
}
|
||||
let modrinth_version_id_refs = modrinth_version_ids
|
||||
.iter()
|
||||
.map(|id| ModrinthVersionId::new(id.clone()))
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
let versions = CachedEntry::get_version_many(
|
||||
&modrinth_version_id_refs,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
on_progress(0.4);
|
||||
let versions_by_id = versions
|
||||
.into_iter()
|
||||
.map(|version| (version.id.clone(), version))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut files = Vec::new();
|
||||
let mut remote_paths = HashSet::new();
|
||||
let project_total = projects.len();
|
||||
for (index, (path, content_file)) in projects.into_iter().enumerate() {
|
||||
on_progress(
|
||||
0.4 + 0.6 * ((index + 1) as f64 / project_total.max(1) as f64),
|
||||
);
|
||||
let disk_path = instance_path.join(path.as_str());
|
||||
let Ok((disk_size, local_sha1)) =
|
||||
crate::util::fetch::sha1_file_async(&disk_path).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(file_size) = u32::try_from(disk_size).ok() else {
|
||||
continue;
|
||||
};
|
||||
let mut remote: Option<(HashMap<PackFileHash, String>, String)> = None;
|
||||
for reference in &content_file.provider_refs {
|
||||
match reference {
|
||||
ContentProviderRef::Modrinth {
|
||||
version_id: Some(version_id),
|
||||
..
|
||||
} => {
|
||||
let Some(version) = versions_by_id.get(version_id.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(version_file) =
|
||||
version.files.iter().find(|file| {
|
||||
file.size == file_size
|
||||
&& file.hashes.get("sha1").is_some_and(|hash| {
|
||||
hash.eq_ignore_ascii_case(&local_sha1)
|
||||
})
|
||||
&& !file.url.trim().is_empty()
|
||||
})
|
||||
{
|
||||
remote = Some((
|
||||
version_file
|
||||
.hashes
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(kind, hash)| {
|
||||
(PackFileHash::from(kind), hash)
|
||||
})
|
||||
.collect(),
|
||||
version_file.url.clone(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
ContentProviderRef::CurseForge {
|
||||
project_id,
|
||||
file_id: Some(file_id),
|
||||
} => {
|
||||
let Ok(file) = crate::api::curseforge::get_file(
|
||||
project_id.get(),
|
||||
file_id.get(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let allowed =
|
||||
crate::api::curseforge::get_project(project_id.get())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|project| project.allow_mod_distribution)
|
||||
.unwrap_or(true);
|
||||
if !allowed {
|
||||
continue;
|
||||
}
|
||||
let hash = file
|
||||
.hashes
|
||||
.iter()
|
||||
.find(|hash| hash.algo == 1)
|
||||
.map(|hash| hash.value.as_str());
|
||||
if file.file_length == u64::from(file_size)
|
||||
&& hash.is_some_and(|hash| {
|
||||
hash.eq_ignore_ascii_case(&local_sha1)
|
||||
})
|
||||
&& file
|
||||
.download_url
|
||||
.as_deref()
|
||||
.is_some_and(|url| !url.trim().is_empty())
|
||||
{
|
||||
let mut hashes = HashMap::new();
|
||||
if let Some(hash) = hash {
|
||||
hashes.insert(PackFileHash::Sha1, hash.to_string());
|
||||
}
|
||||
remote = Some((
|
||||
hashes,
|
||||
file.download_url.unwrap_or_default(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some((hashes, download)) = remote else {
|
||||
continue;
|
||||
};
|
||||
let relative_path = path.as_str().replace('\\', "/");
|
||||
let Ok(path) = mrpack_relative_path(&original_content_relative_path(
|
||||
&relative_path,
|
||||
)) else {
|
||||
continue;
|
||||
};
|
||||
if !remote_paths.insert(path.as_str().to_string()) {
|
||||
continue;
|
||||
}
|
||||
let mut env = HashMap::new();
|
||||
env.insert(EnvType::Client, SideType::Required);
|
||||
env.insert(EnvType::Server, SideType::Required);
|
||||
files.push(PackFile {
|
||||
path,
|
||||
hashes,
|
||||
env: Some(env),
|
||||
downloads: vec![download],
|
||||
file_size,
|
||||
});
|
||||
}
|
||||
on_progress(1.0);
|
||||
|
||||
Ok(PackFormat {
|
||||
game: "minecraft".to_string(),
|
||||
format_version: 1,
|
||||
version_id,
|
||||
name: metadata.instance.name.clone(),
|
||||
summary: description,
|
||||
files,
|
||||
dependencies,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_recursion::async_recursion]
|
||||
async fn add_all_recursive_folder_paths(
|
||||
folder: &PathBuf,
|
||||
output: &mut Vec<PathBuf>,
|
||||
) -> crate::Result<()> {
|
||||
let mut read_dir = io::read_dir(folder).await?;
|
||||
while let Some(entry) = read_dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, folder))?
|
||||
{
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
add_all_recursive_folder_paths(&path, output).await?;
|
||||
} else {
|
||||
output.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::state::{CreateDirectLinkInstance, State};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn mrpack_paths_normalize_windows_separators() {
|
||||
let path =
|
||||
super::mrpack_relative_path(r"config\subdir\options.txt").unwrap();
|
||||
|
||||
assert_eq!(path.as_str(), "config/subdir/options.txt");
|
||||
}
|
||||
|
||||
/// The launcher state is a process-wide singleton; initialize it once and
|
||||
/// reuse it so `State::get()` 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 = TempDir::new().unwrap().keep();
|
||||
let _ =
|
||||
State::init_for_test(root.to_string_lossy().to_string()).await;
|
||||
}
|
||||
State::get().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_link_instances_cannot_be_exported() {
|
||||
let state = global_state().await;
|
||||
let minecraft = TempDir::new().unwrap();
|
||||
let version_dir = minecraft.path().join("versions/export-demo");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
std::fs::write(
|
||||
version_dir.join("export-demo.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"id": "export-demo",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "net.minecraft.client.main.Main"
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let instance = crate::state::create_direct_link_instance(
|
||||
CreateDirectLinkInstance {
|
||||
name: None,
|
||||
launcher_type:
|
||||
crate::api::pack::import::ImportLauncherType::Generic,
|
||||
base_path: minecraft.path().to_path_buf(),
|
||||
instance_folder: "versions/export-demo".to_string(),
|
||||
instance_path: None,
|
||||
game_dir_mode: None,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let export_target = minecraft.path().join("out.mrpack");
|
||||
let error = super::export_mrpack(
|
||||
&instance.id,
|
||||
export_target,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("directly associated"),
|
||||
"expected a friendly rejection, got: {message}"
|
||||
);
|
||||
assert!(
|
||||
!minecraft.path().join("out.mrpack").exists(),
|
||||
"no archive may be written"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::state::{InstanceMetadata, State};
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get(instance_id: &str) -> crate::Result<Option<InstanceMetadata>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::get_instance(instance_id, &state.pool).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_many(
|
||||
instance_ids: &[&str],
|
||||
) -> crate::Result<Vec<InstanceMetadata>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::get_instances_metadata(instance_ids, &state.pool).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn list() -> crate::Result<Vec<InstanceMetadata>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::list_instances(&state.pool).await
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::{
|
||||
DailyPlaytime, DailyPlaytimeEntry, InstanceMetadata, State,
|
||||
};
|
||||
|
||||
pub async fn set_pinned(
|
||||
instance_id: &str,
|
||||
pinned: bool,
|
||||
) -> crate::Result<InstanceMetadata> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::set_instance_pinned(
|
||||
instance_id,
|
||||
pinned,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let instance = crate::state::get_instance(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
.as_error()
|
||||
})?;
|
||||
|
||||
emit_instance(&instance.instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
pub async fn get_daily_playtime(
|
||||
start_date: chrono::NaiveDate,
|
||||
end_date: chrono::NaiveDate,
|
||||
) -> crate::Result<Vec<DailyPlaytime>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::get_daily_playtime(
|
||||
start_date,
|
||||
end_date,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_daily_playtime_details(
|
||||
date: chrono::NaiveDate,
|
||||
) -> crate::Result<Vec<DailyPlaytimeEntry>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::get_daily_playtime_details(
|
||||
date,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::state::{JavaVersion, State};
|
||||
|
||||
pub async fn get_optimal_jre_key(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Option<JavaVersion>> {
|
||||
let state = State::get().await?;
|
||||
let context =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to resolve a nonexistent instance {instance_id}!"
|
||||
))
|
||||
})?;
|
||||
let (minecraft, version_index) =
|
||||
crate::launcher::resolve_minecraft_manifest(
|
||||
&context.applied_content_set.game_version,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
let version = &minecraft.versions[version_index];
|
||||
let loader_version = crate::launcher::get_loader_version_from_profile(
|
||||
&context.applied_content_set.game_version,
|
||||
context.applied_content_set.loader,
|
||||
context.applied_content_set.loader_version.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let version_info = crate::launcher::download::download_version_info(
|
||||
&state,
|
||||
version,
|
||||
context.applied_content_set.loader,
|
||||
loader_version.as_ref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let major_version = version_info
|
||||
.java_version
|
||||
.as_ref()
|
||||
.map_or(8, |java| java.major_version);
|
||||
|
||||
crate::api::jre::find_java_for_version(major_version).await
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{
|
||||
CreateDirectLinkInstance, CreateInstance, EditInstance, InstanceLink,
|
||||
InstanceMetadata, ModLoader, State,
|
||||
};
|
||||
use crate::util::{fetch::write_cached_icon, io};
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tracing::instrument]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create(
|
||||
name: String,
|
||||
game_version: String,
|
||||
modloader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
link: InstanceLink,
|
||||
symlink_target: Option<String>,
|
||||
game_dir_override: Option<String>,
|
||||
) -> crate::Result<InstanceMetadata> {
|
||||
let state = State::get().await?;
|
||||
let instance = crate::state::create_instance(
|
||||
CreateInstance {
|
||||
name,
|
||||
path: None,
|
||||
game_version,
|
||||
loader: modloader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
link,
|
||||
symlink_target,
|
||||
game_dir_override,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = async {
|
||||
emit_instance(&instance.id, InstancePayloadType::Created).await?;
|
||||
|
||||
crate::state::get_instance(&instance.id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Created instance could not be loaded".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = crate::state::remove_instance(&instance.id, &state).await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Creates a "directly associated" instance that launches an externally
|
||||
/// managed (HMCL/PCL) local version in place: nothing is copied, symlinked,
|
||||
/// or reinstalled, and the game directory points at the linked `.minecraft`.
|
||||
#[tracing::instrument]
|
||||
pub async fn create_with_direct_link(
|
||||
input: CreateDirectLinkInstance,
|
||||
) -> crate::Result<InstanceMetadata> {
|
||||
let state = State::get().await?;
|
||||
let instance =
|
||||
crate::state::create_direct_link_instance(input, &state).await?;
|
||||
|
||||
let result = async {
|
||||
emit_instance(&instance.id, InstancePayloadType::Created).await?;
|
||||
|
||||
crate::state::get_instance(&instance.id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Created instance could not be loaded".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = crate::state::remove_instance(&instance.id, &state).await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Reconcile configured external Minecraft roots with direct-link records.
|
||||
pub async fn sync_direct_links(
|
||||
roots: Vec<crate::state::ExternalMinecraftRoot>,
|
||||
) -> crate::Result<crate::state::DirectLinkSyncReport> {
|
||||
let state = State::get().await?;
|
||||
crate::state::sync_direct_link_instances(roots, &state).await
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
instance_id: &str,
|
||||
patch: EditInstance,
|
||||
) -> crate::Result<InstanceMetadata> {
|
||||
let state = State::get().await?;
|
||||
crate::state::edit_instance(instance_id, patch, &state.pool).await?;
|
||||
|
||||
let instance = crate::state::get_instance(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
.as_error()
|
||||
})?;
|
||||
|
||||
emit_instance(&instance.instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
pub async fn cache_icon(
|
||||
icon_name: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let path = write_cached_icon(
|
||||
icon_name,
|
||||
&state.directories.caches_dir(),
|
||||
bytes::Bytes::from(bytes),
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub async fn edit_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<&Path>,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let icon_path = if let Some(icon) = icon_path {
|
||||
let bytes = io::read(icon).await?;
|
||||
let file = crate::util::fetch::write_cached_icon(
|
||||
&icon.to_string_lossy(),
|
||||
&state.directories.caches_dir(),
|
||||
bytes::Bytes::from(bytes),
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
Some(file.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
crate::state::edit_instance(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
icon_path: Some(icon_path),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn remove(instance_id: &str) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?;
|
||||
crate::state::remove_instance(instance_id, &state).await?;
|
||||
|
||||
if let Some(instance) = instance {
|
||||
emit_instance(&instance.id, InstancePayloadType::Removed).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
use crate::state::instances::commands::add_project_bytes_from_provider;
|
||||
use crate::state::{
|
||||
ContentProviderRef, ContentSourceKind, McArchiveFileId, McArchiveProjectId,
|
||||
McArchiveVersionId, ProjectType, State,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McArchiveContentInstallRequest {
|
||||
pub project_id: String,
|
||||
pub project_slug: String,
|
||||
pub version_id: String,
|
||||
pub file_id: String,
|
||||
pub project_type: ProjectType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum McArchiveContentInstallResult {
|
||||
Installed {
|
||||
relative_path: String,
|
||||
},
|
||||
ManualDownload {
|
||||
file_name: String,
|
||||
page_url: Option<String>,
|
||||
expected_sha256: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(request))]
|
||||
pub async fn install_mcarchive_content(
|
||||
instance_id: &str,
|
||||
request: McArchiveContentInstallRequest,
|
||||
) -> crate::Result<McArchiveContentInstallResult> {
|
||||
let (project, version, file) = resolve_requested_file(&request).await?;
|
||||
if file.needs_manual_download() {
|
||||
return Ok(manual_download_result(&project, &file));
|
||||
}
|
||||
|
||||
let bytes = crate::api::mcarchive::download_file(&file).await?;
|
||||
let relative_path = record_verified_file(
|
||||
instance_id,
|
||||
&request,
|
||||
project,
|
||||
version,
|
||||
file,
|
||||
bytes.into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(McArchiveContentInstallResult::Installed { relative_path })
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(request, source_path))]
|
||||
pub async fn import_mcarchive_content(
|
||||
instance_id: &str,
|
||||
request: McArchiveContentInstallRequest,
|
||||
source_path: PathBuf,
|
||||
) -> crate::Result<McArchiveContentInstallResult> {
|
||||
let (project, version, file) = resolve_requested_file(&request).await?;
|
||||
let expected_sha256 = file
|
||||
.sha256
|
||||
.as_deref()
|
||||
.filter(|hash| !hash.trim().is_empty());
|
||||
let Some(expected_sha256) = expected_sha256 else {
|
||||
return Ok(manual_download_result(&project, &file));
|
||||
};
|
||||
let metadata = tokio::fs::metadata(&source_path).await?;
|
||||
if !metadata.is_file() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"The selected MCArchive import must be a regular file".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let source_path = crate::util::io::canonicalize(&source_path)?;
|
||||
let actual_sha256 = sha256_file(&source_path).await?;
|
||||
if !actual_sha256.eq_ignore_ascii_case(expected_sha256) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"The selected file does not match MCArchive SHA-256 for {}",
|
||||
file.name
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let bytes = tokio::fs::read(&source_path).await?;
|
||||
let relative_path = record_verified_file(
|
||||
instance_id,
|
||||
&request,
|
||||
project,
|
||||
version,
|
||||
file,
|
||||
Bytes::from(bytes),
|
||||
)
|
||||
.await?;
|
||||
Ok(McArchiveContentInstallResult::Installed { relative_path })
|
||||
}
|
||||
|
||||
async fn resolve_requested_file(
|
||||
request: &McArchiveContentInstallRequest,
|
||||
) -> crate::Result<(
|
||||
crate::mcarchive::McArchiveMod,
|
||||
crate::mcarchive::McArchiveModVersion,
|
||||
crate::mcarchive::McArchiveFile,
|
||||
)> {
|
||||
if request.project_slug.trim().is_empty()
|
||||
|| request.project_id.trim().is_empty()
|
||||
|| request.version_id.trim().is_empty()
|
||||
|| request.file_id.trim().is_empty()
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"MCArchive project, version, and file identifiers are required"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let project =
|
||||
crate::api::mcarchive::get_mod_by_slug(&request.project_slug).await?;
|
||||
if project.uuid != request.project_id {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"The selected MCArchive project no longer matches its identifier"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let version = project
|
||||
.mod_versions
|
||||
.iter()
|
||||
.find(|version| version.uuid == request.version_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected MCArchive version is no longer available"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let file = version
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.uuid == request.file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected MCArchive file is no longer available"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok((project, version, file))
|
||||
}
|
||||
|
||||
async fn record_verified_file(
|
||||
instance_id: &str,
|
||||
request: &McArchiveContentInstallRequest,
|
||||
project: crate::mcarchive::McArchiveMod,
|
||||
version: crate::mcarchive::McArchiveModVersion,
|
||||
file: crate::mcarchive::McArchiveFile,
|
||||
bytes: Bytes,
|
||||
) -> crate::Result<String> {
|
||||
let provider_ref = ContentProviderRef::McArchive {
|
||||
project_id: McArchiveProjectId::new(project.uuid)?,
|
||||
version_id: Some(McArchiveVersionId::new(version.uuid)?),
|
||||
file_id: Some(McArchiveFileId::new(file.uuid)?),
|
||||
};
|
||||
let state = State::get().await?;
|
||||
let relative_path = add_project_bytes_from_provider(
|
||||
instance_id,
|
||||
&file.name,
|
||||
bytes,
|
||||
None,
|
||||
request.project_type,
|
||||
ContentSourceKind::McArchive,
|
||||
&provider_ref,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::emit_content_changed(instance_id).await?;
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
fn manual_download_result(
|
||||
project: &crate::mcarchive::McArchiveMod,
|
||||
file: &crate::mcarchive::McArchiveFile,
|
||||
) -> McArchiveContentInstallResult {
|
||||
McArchiveContentInstallResult::ManualDownload {
|
||||
file_name: file.name.clone(),
|
||||
page_url: file
|
||||
.manual_download_url()
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| project.page_url.clone()),
|
||||
expected_sha256: file.sha256.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sha256_file(path: &Path) -> crate::Result<String> {
|
||||
let path = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut file = std::fs::File::open(&path).map_err(|error| {
|
||||
crate::util::io::IOError::with_path(error, &path)
|
||||
})?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0_u8; 262_144];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manual_route_preserves_the_provider_download_page() {
|
||||
let project = crate::mcarchive::McArchiveMod {
|
||||
uuid: "project".to_string(),
|
||||
slug: "project".to_string(),
|
||||
name: "Project".to_string(),
|
||||
summary: None,
|
||||
description: None,
|
||||
page_url: Some("https://mcarchive.net/mod/project".to_string()),
|
||||
mod_versions: Vec::new(),
|
||||
};
|
||||
let file = crate::mcarchive::McArchiveFile {
|
||||
uuid: "file".to_string(),
|
||||
name: "project.jar".to_string(),
|
||||
sha256: None,
|
||||
archive_url: None,
|
||||
direct_url: None,
|
||||
redirect_url: None,
|
||||
page_url: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
manual_download_result(&project, &file),
|
||||
McArchiveContentInstallResult::ManualDownload {
|
||||
page_url: Some(page_url),
|
||||
..
|
||||
} if page_url == "https://mcarchive.net/mod/project"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use crate::launcher::instance_runtime::InstanceRuntimeAdapter;
|
||||
use crate::state::State;
|
||||
use crate::util::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_full_path(instance_id: &str) -> crate::Result<PathBuf> {
|
||||
let state = State::get().await?;
|
||||
let instance = crate::state::get_instance(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
// Directly associated instances never have a profile directory under
|
||||
// `profiles`; interactive APIs (open folder, worlds, servers, ...) must
|
||||
// operate on the linked `.minecraft` instead.
|
||||
let adapter = InstanceRuntimeAdapter::for_instance(
|
||||
&instance.instance,
|
||||
&state.directories,
|
||||
)?;
|
||||
Ok(io::canonicalize(adapter.game_dir())?)
|
||||
|
||||
// `instance_game_dir` honours a per-instance `game_dir_override` before
|
||||
// falling back to the profile directory.
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_mod_full_path(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
) -> crate::Result<PathBuf> {
|
||||
Ok(get_full_path(instance_id).await?.join(project_path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::CreateDirectLinkInstance;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// The launcher state is a process-wide singleton; initialize it once and
|
||||
/// reuse it so `State::get()` 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 = TempDir::new().unwrap().keep();
|
||||
let _ =
|
||||
State::init_for_test(root.to_string_lossy().to_string()).await;
|
||||
}
|
||||
State::get().await.unwrap()
|
||||
}
|
||||
|
||||
async fn create_direct_link_fixture(
|
||||
label: &str,
|
||||
) -> (TempDir, crate::state::InstanceMetadata) {
|
||||
let state = global_state().await;
|
||||
let minecraft = TempDir::new().unwrap();
|
||||
let version_dir = minecraft.path().join("versions").join(label);
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
std::fs::write(
|
||||
version_dir.join(format!("{label}.json")),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"id": label,
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "net.minecraft.client.main.Main"
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let instance = crate::state::create_direct_link_instance(
|
||||
CreateDirectLinkInstance {
|
||||
name: None,
|
||||
launcher_type:
|
||||
crate::api::pack::import::ImportLauncherType::Generic,
|
||||
base_path: minecraft.path().to_path_buf(),
|
||||
instance_folder: format!("versions/{label}"),
|
||||
instance_path: None,
|
||||
game_dir_mode: None,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let metadata = crate::state::get_instance(&instance.id, &state.pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("metadata for created fixture");
|
||||
(minecraft, metadata)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_link_instance_resolves_to_isolated_version_directory() {
|
||||
let state = global_state().await;
|
||||
let (_minecraft, metadata) =
|
||||
create_direct_link_fixture("paths-demo").await;
|
||||
|
||||
let resolved = get_full_path(&metadata.instance.id).await.unwrap();
|
||||
|
||||
assert!(resolved.ends_with(Path::new("versions").join("paths-demo")));
|
||||
assert!(
|
||||
!state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&metadata.instance.path)
|
||||
.exists(),
|
||||
"no profile directory may be created for a direct-link instance"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_direct_link_instance_resolves_to_minecraft_root() {
|
||||
let state = global_state().await;
|
||||
let minecraft = TempDir::new().unwrap();
|
||||
let version_name = "paths-shared";
|
||||
let version_dir = minecraft.path().join("versions").join(version_name);
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
std::fs::write(
|
||||
version_dir.join(format!("{version_name}.json")),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"id": version_name,
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "net.minecraft.client.main.Main"
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let instance = crate::state::create_direct_link_instance(
|
||||
CreateDirectLinkInstance {
|
||||
name: None,
|
||||
launcher_type:
|
||||
crate::api::pack::import::ImportLauncherType::Generic,
|
||||
base_path: minecraft.path().to_path_buf(),
|
||||
instance_folder: Path::new("versions")
|
||||
.join(version_name)
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
instance_path: None,
|
||||
game_dir_mode: Some(
|
||||
crate::launcher::ExternalGameDirMode::Shared,
|
||||
),
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resolved = get_full_path(&instance.id).await.unwrap();
|
||||
|
||||
assert_eq!(resolved, io::canonicalize(minecraft.path()).unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_link_file_browser_lists_linked_content() {
|
||||
let _state = global_state().await;
|
||||
let (minecraft, metadata) =
|
||||
create_direct_link_fixture("paths-browse").await;
|
||||
|
||||
// The file browser lists directories relative to the resolved root.
|
||||
let version_dir =
|
||||
minecraft.path().join("versions").join("paths-browse");
|
||||
std::fs::create_dir_all(version_dir.join("mods")).unwrap();
|
||||
std::fs::write(
|
||||
version_dir.join("mods").join("browse-fixture.jar"),
|
||||
b"browse fixture",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = get_full_path(&metadata.instance.id).await.unwrap();
|
||||
let listed = root.join("mods").read_dir().unwrap();
|
||||
let names = listed
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["browse-fixture.jar".to_string()],
|
||||
"the browser root must expose the linked installation's content"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_instance_still_resolves_to_its_profile_directory() {
|
||||
let state = global_state().await;
|
||||
let metadata = crate::api::instance::create(
|
||||
format!("paths-normal {}", uuid::Uuid::new_v4()),
|
||||
"1.20.1".to_string(),
|
||||
crate::state::ModLoader::Vanilla,
|
||||
None,
|
||||
None,
|
||||
crate::state::InstanceLink::Unmanaged,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
std::fs::create_dir_all(
|
||||
state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&metadata.instance.path),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = get_full_path(&metadata.instance.id).await.unwrap();
|
||||
assert_eq!(
|
||||
resolved,
|
||||
io::canonicalize(
|
||||
state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&metadata.instance.path)
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use crate::api::planet_minecraft::PlanetMinecraftInstallRoute;
|
||||
use crate::state::instances::commands::add_project_bytes;
|
||||
use crate::state::{ContentSourceKind, ProjectType, State};
|
||||
use bytes::Bytes;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlanetMinecraftContentInstallRequest {
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
pub project_type: ProjectType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum PlanetMinecraftContentInstallResult {
|
||||
Installed {
|
||||
relative_path: String,
|
||||
},
|
||||
ManualDownload {
|
||||
page_url: String,
|
||||
file_name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn install_planet_minecraft_content(
|
||||
instance_id: &str,
|
||||
request: PlanetMinecraftContentInstallRequest,
|
||||
) -> crate::Result<PlanetMinecraftContentInstallResult> {
|
||||
let project =
|
||||
crate::api::planet_minecraft::get_project(&request.project_id).await?;
|
||||
let version = project
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.id == request.version_id)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected Planet Minecraft release is no longer available"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
match version.download.install_route() {
|
||||
PlanetMinecraftInstallRoute::Manual {
|
||||
page_url,
|
||||
file_name,
|
||||
} => Ok(PlanetMinecraftContentInstallResult::ManualDownload {
|
||||
page_url,
|
||||
file_name,
|
||||
}),
|
||||
PlanetMinecraftInstallRoute::Automatic {
|
||||
direct_url,
|
||||
sha256,
|
||||
file_name,
|
||||
} => {
|
||||
let bytes = crate::api::planet_minecraft::download_verified_file(
|
||||
&direct_url,
|
||||
&sha256,
|
||||
)
|
||||
.await?;
|
||||
let name =
|
||||
file_name.unwrap_or_else(|| format!("{}.jar", version.name));
|
||||
let state = State::get().await?;
|
||||
let relative_path = add_project_bytes(
|
||||
instance_id,
|
||||
&name,
|
||||
Bytes::from(bytes),
|
||||
None,
|
||||
Some(request.project_type),
|
||||
ContentSourceKind::Local,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::emit_content_changed(instance_id).await?;
|
||||
Ok(
|
||||
PlanetMinecraftContentInstallResult::Installed {
|
||||
relative_path,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn import_planet_minecraft_content(
|
||||
instance_id: &str,
|
||||
request: PlanetMinecraftContentInstallRequest,
|
||||
source_path: PathBuf,
|
||||
) -> crate::Result<PlanetMinecraftContentInstallResult> {
|
||||
let project =
|
||||
crate::api::planet_minecraft::get_project(&request.project_id).await?;
|
||||
let version = project
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.id == request.version_id)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected Planet Minecraft release is no longer available"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let PlanetMinecraftInstallRoute::Manual {
|
||||
file_name,
|
||||
page_url: _,
|
||||
} = version.download.install_route()
|
||||
else {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"This Planet Minecraft release has a verified direct download; use automatic installation"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
};
|
||||
let metadata = tokio::fs::metadata(&source_path).await?;
|
||||
if !metadata.is_file() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"The selected Planet Minecraft import must be a regular file"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let bytes = tokio::fs::read(&source_path).await?;
|
||||
let state = State::get().await?;
|
||||
let relative_path = add_project_bytes(
|
||||
instance_id,
|
||||
file_name.as_deref().unwrap_or("planet-minecraft.jar"),
|
||||
Bytes::from(bytes),
|
||||
None,
|
||||
Some(request.project_type),
|
||||
ContentSourceKind::Local,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
super::emit_content_changed(instance_id).await?;
|
||||
Ok(PlanetMinecraftContentInstallResult::Installed { relative_path })
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
use crate::event::emit::{emit_instance, emit_loading, init_loading};
|
||||
use crate::event::{InstancePayloadType, LoadingBarType};
|
||||
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
|
||||
use crate::state::instances::{
|
||||
ContentOwnershipKind, PackMemberMaterializationState,
|
||||
PackMemberOverrideKind,
|
||||
};
|
||||
use crate::state::{ContentProvider, ContentSourceKind, ProjectType, State};
|
||||
use crate::util::fetch;
|
||||
use modrinth_content_management::{
|
||||
ContentType, ResolutionPreferences, ResolveContentPlan,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct InstallProjectWithDependenciesRequest {
|
||||
pub project_id: String,
|
||||
pub version_id: Option<String>,
|
||||
pub content_type: ContentType,
|
||||
#[serde(default)]
|
||||
pub selected: ResolutionPreferences,
|
||||
#[serde(default)]
|
||||
pub excluded_project_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub force_project_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentToggleResult {
|
||||
pub content_id: String,
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_all_projects(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<HashMap<String, String>> {
|
||||
let state = State::get().await?;
|
||||
let instance = get_instance_display_info(instance_id, &state).await?;
|
||||
let loading_bar = init_loading(
|
||||
LoadingBarType::InstanceUpdate {
|
||||
instance_id: instance.id.clone(),
|
||||
instance_name: instance.name.clone(),
|
||||
},
|
||||
100.0,
|
||||
"Updating instance",
|
||||
)
|
||||
.await?;
|
||||
let map = crate::state::instances::commands::update_all_projects(
|
||||
instance_id,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_loading(&loading_bar, 100.0, Some("Updated instance"))?;
|
||||
emit_content_changed(&instance.id).await?;
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
skip_send_event: Option<bool>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let path = crate::state::instances::commands::update_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !skip_send_event.unwrap_or(false) {
|
||||
emit_content_changed(instance_id).await?;
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn add_project_from_version(
|
||||
instance_id: &str,
|
||||
version_id: &str,
|
||||
reason: fetch::DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let project_path =
|
||||
crate::state::instances::commands::add_project_from_version(
|
||||
instance_id,
|
||||
version_id,
|
||||
reason,
|
||||
dependent_on_version_id,
|
||||
crate::state::ContentSourceKind::Local,
|
||||
crate::state::instances::ContentOwnershipKind::UserAdded,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_content_changed(instance_id).await?;
|
||||
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn install_project_with_dependencies(
|
||||
instance_id: &str,
|
||||
request: InstallProjectWithDependenciesRequest,
|
||||
) -> crate::Result<ResolveContentPlan> {
|
||||
let state = State::get().await?;
|
||||
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let plan = crate::state::instances::commands::resolve_install_plan(
|
||||
instance_id,
|
||||
crate::state::instances::commands::InstanceInstallProjectRequest {
|
||||
project_id: request.project_id,
|
||||
version_id: request.version_id,
|
||||
content_type: request.content_type,
|
||||
selected: request.selected,
|
||||
excluded_project_ids: request.excluded_project_ids,
|
||||
force_project_ids: request.force_project_ids,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let instance_id = metadata.instance.id;
|
||||
let project_ids = plan_project_ids(&plan);
|
||||
let install_plan = plan.clone();
|
||||
tokio::spawn(async move {
|
||||
match crate::state::instances::commands::install_resolved_content_plan(
|
||||
&instance_id,
|
||||
&install_plan,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_paths) => {
|
||||
if let Err(error) = emit_instance(
|
||||
&instance_id,
|
||||
InstancePayloadType::ContentInstallFinished {
|
||||
project_ids: project_ids.clone(),
|
||||
dependency_project_ids: plan_project_ids(&install_plan)
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
.collect(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to emit content install finished event: {error}"
|
||||
);
|
||||
}
|
||||
if let Err(error) = emit_content_changed(&instance_id).await {
|
||||
tracing::error!(
|
||||
"Failed to emit instance edited event after content install: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if let Err(emit_error) = emit_instance(
|
||||
&instance_id,
|
||||
InstancePayloadType::ContentInstallFailed {
|
||||
project_ids,
|
||||
message: error.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to emit content install failed event: {emit_error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn preview_project_with_dependencies(
|
||||
instance_id: &str,
|
||||
request: InstallProjectWithDependenciesRequest,
|
||||
) -> crate::Result<ResolveContentPlan> {
|
||||
let state = State::get().await?;
|
||||
super::get::get(instance_id).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
crate::state::instances::commands::resolve_install_plan(
|
||||
instance_id,
|
||||
crate::state::instances::commands::InstanceInstallProjectRequest {
|
||||
project_id: request.project_id,
|
||||
version_id: request.version_id,
|
||||
content_type: request.content_type,
|
||||
selected: request.selected,
|
||||
excluded_project_ids: request.excluded_project_ids,
|
||||
force_project_ids: request.force_project_ids,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn preview_project_with_dependencies_for_target(
|
||||
request: InstallProjectWithDependenciesRequest,
|
||||
game_version: String,
|
||||
loader: crate::state::ModLoader,
|
||||
) -> crate::Result<ResolveContentPlan> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::resolve_install_plan_for_target(
|
||||
crate::state::instances::commands::InstanceInstallProjectRequest {
|
||||
project_id: request.project_id,
|
||||
version_id: request.version_id,
|
||||
content_type: request.content_type,
|
||||
selected: request.selected,
|
||||
excluded_project_ids: request.excluded_project_ids,
|
||||
force_project_ids: request.force_project_ids,
|
||||
},
|
||||
game_version,
|
||||
loader,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn queue_project_with_dependencies(
|
||||
instance_id: &str,
|
||||
request: InstallProjectWithDependenciesRequest,
|
||||
display_title: String,
|
||||
display_icon: Option<String>,
|
||||
) -> crate::Result<crate::install::InstallJobSnapshot> {
|
||||
crate::install::install_content(
|
||||
instance_id.to_string(),
|
||||
request.project_id,
|
||||
request.version_id,
|
||||
request.content_type,
|
||||
request.selected,
|
||||
request.excluded_project_ids,
|
||||
display_title,
|
||||
display_icon,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn queue_curseforge_content(
|
||||
request: crate::api::curseforge::CurseForgeInstallRequest,
|
||||
display_title: String,
|
||||
display_icon: Option<String>,
|
||||
) -> crate::Result<crate::install::InstallJobSnapshot> {
|
||||
crate::install::install_curseforge_content(
|
||||
request,
|
||||
display_title,
|
||||
display_icon,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn queue_curseforge_world(
|
||||
request: crate::api::curseforge::CurseForgeWorldInstallRequest,
|
||||
display_title: String,
|
||||
display_icon: Option<String>,
|
||||
) -> crate::Result<crate::install::InstallJobSnapshot> {
|
||||
crate::install::install_curseforge_world(
|
||||
request,
|
||||
display_title,
|
||||
display_icon,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn plan_project_ids(plan: &ResolveContentPlan) -> Vec<String> {
|
||||
let mut project_ids = Vec::with_capacity(plan.dependencies.len() + 1);
|
||||
project_ids.push(plan.primary.project_id.clone());
|
||||
project_ids.extend(
|
||||
plan.dependencies
|
||||
.iter()
|
||||
.map(|dependency| dependency.project_id.clone()),
|
||||
);
|
||||
project_ids
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn switch_project_version_with_dependencies(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
version_id: &str,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let path =
|
||||
crate::state::instances::commands::switch_project_version_with_dependencies(
|
||||
instance_id,
|
||||
project_path,
|
||||
version_id,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_content_changed(&metadata.instance.id).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn add_project_from_path(
|
||||
instance_id: &str,
|
||||
path: &Path,
|
||||
project_type: Option<ProjectType>,
|
||||
inner_base: Option<&str>,
|
||||
) -> crate::Result<String> {
|
||||
if let Some(relative_path) =
|
||||
crate::api::curseforge::import_pending_manual_download_from_path(
|
||||
instance_id,
|
||||
path,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
emit_content_changed(instance_id).await?;
|
||||
return Ok(relative_path);
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
let result = crate::state::instances::commands::add_project_from_path(
|
||||
instance_id,
|
||||
path,
|
||||
project_type,
|
||||
inner_base,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_ok() {
|
||||
emit_instance(instance_id, InstancePayloadType::Synced).await?;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn import_world_save(
|
||||
instance_id: &str,
|
||||
source_path: &Path,
|
||||
inner_base: Option<&str>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::import_world_save(
|
||||
&state,
|
||||
instance_id,
|
||||
source_path,
|
||||
inner_base,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn install_datapack_to_world(
|
||||
instance_id: &str,
|
||||
world_path: &str,
|
||||
source_path: &Path,
|
||||
inner_base: Option<&str>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::install_datapack_to_world(
|
||||
instance_id,
|
||||
world_path,
|
||||
source_path,
|
||||
inner_base,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn install_datapack_bytes_to_world(
|
||||
instance_id: &str,
|
||||
world_path: &str,
|
||||
file_name: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::install_datapack_bytes_to_world(
|
||||
instance_id,
|
||||
world_path,
|
||||
file_name,
|
||||
&bytes,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn toggle_disable_project(
|
||||
instance_id: &str,
|
||||
project: &str,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let res = crate::state::instances::commands::toggle_disable_project(
|
||||
instance_id,
|
||||
project,
|
||||
desired_enabled,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_content_changed(instance_id).await?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn rollback_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let res = crate::state::instances::commands::rollback_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_content_changed(instance_id).await?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn remove_project(
|
||||
instance_id: &str,
|
||||
project: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::remove_project(
|
||||
instance_id,
|
||||
project,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_content_changed(instance_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn toggle_content_entry(
|
||||
instance_id: &str,
|
||||
content_id: &str,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> crate::Result<String> {
|
||||
let mut results = toggle_content_entries(
|
||||
instance_id,
|
||||
vec![content_id.to_string()],
|
||||
desired_enabled,
|
||||
)
|
||||
.await?;
|
||||
results.pop().map(|result| result.path).ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected content no longer exists".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn toggle_content_entries(
|
||||
instance_id: &str,
|
||||
content_ids: Vec<String>,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> crate::Result<Vec<ContentToggleResult>> {
|
||||
let state = State::get().await?;
|
||||
let results = crate::state::instances::commands::toggle_content_entries(
|
||||
instance_id,
|
||||
&content_ids,
|
||||
desired_enabled,
|
||||
&state,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|result| ContentToggleResult {
|
||||
content_id: result.content_id,
|
||||
path: result.path,
|
||||
enabled: result.enabled,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !results.is_empty() {
|
||||
emit_content_changed(instance_id).await?;
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn remove_content_entry(
|
||||
instance_id: &str,
|
||||
content_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
let target = content_mutation_target(instance_id, content_id).await?;
|
||||
let path = target.relative_path.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected content is not present on disk".to_string(),
|
||||
)
|
||||
})?;
|
||||
remove_project(instance_id, &path).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_content_entry(
|
||||
instance_id: &str,
|
||||
content_id: &str,
|
||||
) -> crate::Result<String> {
|
||||
let target = content_mutation_target(instance_id, content_id).await?;
|
||||
let path = target.relative_path.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected content is not present on disk".to_string(),
|
||||
)
|
||||
})?;
|
||||
match target.provider {
|
||||
Some(ContentProvider::CurseForge) => {
|
||||
let result = crate::api::curseforge::update_installed_file(
|
||||
instance_id,
|
||||
&path,
|
||||
)
|
||||
.await?;
|
||||
let updated_path = result
|
||||
.installed
|
||||
.iter()
|
||||
.find(|file| !file.dependency)
|
||||
.map_or(path, |file| file.relative_path.clone());
|
||||
emit_content_changed(instance_id).await?;
|
||||
Ok(updated_path)
|
||||
}
|
||||
Some(ContentProvider::McArchive) => Err(crate::ErrorKind::InputError(
|
||||
"MCArchive content updates require selecting a file manually"
|
||||
.to_string(),
|
||||
)
|
||||
.into()),
|
||||
_ => update_project(instance_id, &path, None).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn switch_content_entry_version(
|
||||
instance_id: &str,
|
||||
content_id: &str,
|
||||
version_id: &str,
|
||||
) -> crate::Result<String> {
|
||||
let target = content_mutation_target(instance_id, content_id).await?;
|
||||
let path = target.relative_path.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected content is not present on disk".to_string(),
|
||||
)
|
||||
})?;
|
||||
match target.provider {
|
||||
Some(ContentProvider::CurseForge) => {
|
||||
let file_id = version_id.parse::<u32>().map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected CurseForge file ID is invalid".to_string(),
|
||||
)
|
||||
})?;
|
||||
let result = crate::api::curseforge::switch_installed_file_version(
|
||||
instance_id,
|
||||
&path,
|
||||
file_id,
|
||||
)
|
||||
.await?;
|
||||
let updated_path = result
|
||||
.installed
|
||||
.iter()
|
||||
.find(|file| !file.dependency)
|
||||
.map_or(path, |file| file.relative_path.clone());
|
||||
emit_content_changed(instance_id).await?;
|
||||
Ok(updated_path)
|
||||
}
|
||||
Some(ContentProvider::McArchive) => Err(crate::ErrorKind::InputError(
|
||||
"MCArchive content version changes require selecting a file manually"
|
||||
.to_string(),
|
||||
)
|
||||
.into()),
|
||||
_ => {
|
||||
switch_project_version_with_dependencies(
|
||||
instance_id,
|
||||
&path,
|
||||
version_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn restore_pack_member_default(
|
||||
instance_id: &str,
|
||||
member_id: &str,
|
||||
) -> crate::Result<Option<String>> {
|
||||
let state = State::get().await?;
|
||||
let target = content_rows::get_content_mutation_target(
|
||||
instance_id,
|
||||
member_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected pack member no longer exists".to_string(),
|
||||
)
|
||||
})?;
|
||||
if target.member_id.as_deref() != Some(member_id) {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Restore requires a pack member ID".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let member = content_rows::get_pack_members(
|
||||
&content_rows::get_applied_content_set(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance has no applied content set".to_string(),
|
||||
)
|
||||
})?
|
||||
.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|member| member.id == member_id)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected pack member no longer exists".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if member.override_kind == PackMemberOverrideKind::None
|
||||
&& member.materialization_state
|
||||
== PackMemberMaterializationState::Present
|
||||
{
|
||||
return Ok(target.relative_path);
|
||||
}
|
||||
if member.override_kind == PackMemberOverrideKind::Disabled
|
||||
&& let Some(path) = target.relative_path
|
||||
{
|
||||
return toggle_disable_project(instance_id, &path, Some(true))
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
|
||||
let project_id =
|
||||
member.provider_project_id.as_deref().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"This pack member has no provider project to restore from"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let release_id =
|
||||
member.provider_release_id.as_deref().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"This pack member has no original version to restore"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let old_path = target.relative_path;
|
||||
let (restored_path, pending_manual) = match member.provider {
|
||||
Some(ContentProvider::Modrinth) => (
|
||||
Some(
|
||||
crate::state::instances::commands::add_project_from_version(
|
||||
instance_id,
|
||||
release_id,
|
||||
fetch::DownloadReason::Update,
|
||||
None,
|
||||
ContentSourceKind::ModrinthModpack,
|
||||
ContentOwnershipKind::PackManaged,
|
||||
&state,
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
false,
|
||||
),
|
||||
Some(ContentProvider::CurseForge) => {
|
||||
let content_set =
|
||||
content_rows::get_applied_content_set(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance has no applied content set".to_string(),
|
||||
)
|
||||
})?;
|
||||
let result = crate::api::curseforge::install_file(
|
||||
crate::api::curseforge::CurseForgeInstallRequest {
|
||||
instance_id: instance_id.to_string(),
|
||||
project_id: project_id.parse().map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Stored CurseForge project ID is invalid".to_string(),
|
||||
)
|
||||
})?,
|
||||
file_id: release_id.parse().map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Stored CurseForge file ID is invalid".to_string(),
|
||||
)
|
||||
})?,
|
||||
project_type: member.project_type.get_name().to_string(),
|
||||
ownership_kind: ContentOwnershipKind::PackManaged,
|
||||
manual_operation_kind: crate::state::instances::ManualDownloadOperationKind::ContentUpdate,
|
||||
game_version: Some(content_set.game_version),
|
||||
mod_loader_type: curseforge_loader_type(content_set.loader),
|
||||
world_name: None,
|
||||
install_dependencies: false,
|
||||
excluded_dependency_project_ids: Vec::new(),
|
||||
force_dependency_project_ids: Vec::new(),
|
||||
dependency_plan_id: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let pending_manual = !result.manual_downloads.is_empty();
|
||||
let failure_reason = result
|
||||
.failed_downloads
|
||||
.first()
|
||||
.map(|failure| failure.reason.clone());
|
||||
let restored_path = result
|
||||
.installed
|
||||
.into_iter()
|
||||
.find(|file| !file.dependency)
|
||||
.map(|file| file.relative_path);
|
||||
if restored_path.is_none() && !pending_manual {
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
failure_reason.unwrap_or_else(|| {
|
||||
"CurseForge did not return the restored file"
|
||||
.to_string()
|
||||
}),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
(restored_path, pending_manual)
|
||||
}
|
||||
Some(ContentProvider::Local) => {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"This pack member has no managed provider".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Some(ContentProvider::McArchive) => {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"MCArchive pack members must be restored from an imported file"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
None => {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"This pack member has no managed provider".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
|
||||
if let (Some(old_path), Some(new_path)) =
|
||||
(old_path.as_deref(), restored_path.as_deref())
|
||||
&& old_path != new_path
|
||||
&& crate::state::instances::commands::archive_project_file(
|
||||
instance_id,
|
||||
old_path,
|
||||
new_path,
|
||||
&state,
|
||||
)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
crate::state::instances::commands::remove_project(
|
||||
instance_id,
|
||||
old_path,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let content_set =
|
||||
content_rows::get_applied_content_set(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance has no applied content set".to_string(),
|
||||
)
|
||||
})?;
|
||||
let _instance_lock = state.lock_instance_content(instance_id).await;
|
||||
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
content_rows::set_pack_member_override_in_transaction(
|
||||
member_id,
|
||||
if pending_manual {
|
||||
PackMemberMaterializationState::PendingManual
|
||||
} else {
|
||||
PackMemberMaterializationState::Present
|
||||
},
|
||||
PackMemberOverrideKind::None,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::bump_content_set_revision_in_transaction(
|
||||
&content_set.id,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
emit_content_changed(instance_id).await?;
|
||||
Ok(restored_path)
|
||||
}
|
||||
|
||||
pub(crate) async fn emit_content_changed(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let content_set =
|
||||
content_rows::get_applied_content_set(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance has no applied content set".to_string(),
|
||||
)
|
||||
})?;
|
||||
emit_instance(
|
||||
instance_id,
|
||||
InstancePayloadType::ContentChanged {
|
||||
revision: content_set.revision,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn content_mutation_target(
|
||||
instance_id: &str,
|
||||
content_id: &str,
|
||||
) -> crate::Result<content_rows::ContentMutationTarget> {
|
||||
let state = State::get().await?;
|
||||
content_rows::get_content_mutation_target(
|
||||
instance_id,
|
||||
content_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The selected content no longer exists".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn curseforge_loader_type(loader: crate::state::ModLoader) -> Option<u32> {
|
||||
match loader {
|
||||
crate::state::ModLoader::Forge | crate::state::ModLoader::Cleanroom => {
|
||||
Some(1)
|
||||
}
|
||||
crate::state::ModLoader::LiteLoader => Some(3),
|
||||
crate::state::ModLoader::Fabric
|
||||
| crate::state::ModLoader::LegacyFabric => Some(4),
|
||||
crate::state::ModLoader::Quilt => Some(5),
|
||||
crate::state::ModLoader::NeoForge => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_managed_modrinth_version(
|
||||
instance_id: &str,
|
||||
version_id: &str,
|
||||
) -> crate::Result<crate::install::InstallJobSnapshot> {
|
||||
let state = State::get().await?;
|
||||
let metadata = crate::state::instances::commands::get_instance_metadata(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
let post_install_edit = match &metadata.link {
|
||||
crate::state::InstanceLink::ServerProjectModpack {
|
||||
server_project_id,
|
||||
content_project_id,
|
||||
..
|
||||
} => Some(crate::install::InstallPostInstallEdit {
|
||||
name: Some(metadata.instance.name.clone()),
|
||||
icon_path: Some(metadata.instance.icon_path.clone()),
|
||||
link: Some(crate::state::InstanceLink::ServerProjectModpack {
|
||||
server_project_id: server_project_id.clone(),
|
||||
content_project_id: content_project_id.clone(),
|
||||
content_version_id: version_id.to_string(),
|
||||
}),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let project_id = match &metadata.link {
|
||||
crate::state::InstanceLink::ModrinthModpack { project_id, .. } => {
|
||||
project_id.clone()
|
||||
}
|
||||
crate::state::InstanceLink::ServerProjectModpack {
|
||||
content_project_id,
|
||||
..
|
||||
} => content_project_id.clone(),
|
||||
_ => {
|
||||
return Err(unmanaged_pack_error(&metadata.instance.id).into());
|
||||
}
|
||||
};
|
||||
|
||||
crate::install::install_pack_to_existing_instance(
|
||||
metadata.instance.id,
|
||||
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
|
||||
project_id,
|
||||
version_id: version_id.to_string(),
|
||||
title: metadata.instance.name.clone(),
|
||||
icon_url: None,
|
||||
},
|
||||
post_install_edit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn repair_managed_modrinth(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<crate::install::InstallJobSnapshot> {
|
||||
let state = State::get().await?;
|
||||
let metadata = crate::state::instances::commands::get_instance_metadata(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
let post_install_edit = match &metadata.link {
|
||||
crate::state::InstanceLink::ServerProjectModpack { .. } => {
|
||||
Some(crate::install::InstallPostInstallEdit {
|
||||
name: Some(metadata.instance.name.clone()),
|
||||
icon_path: Some(metadata.instance.icon_path.clone()),
|
||||
link: Some(metadata.link.clone()),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let (project_id, version_id) = match &metadata.link {
|
||||
crate::state::InstanceLink::ModrinthModpack {
|
||||
project_id,
|
||||
version_id,
|
||||
} => (project_id.clone(), version_id.clone()),
|
||||
crate::state::InstanceLink::ServerProjectModpack {
|
||||
content_project_id,
|
||||
content_version_id,
|
||||
..
|
||||
} => (content_project_id.clone(), content_version_id.clone()),
|
||||
_ => {
|
||||
return Err(unmanaged_pack_error(&metadata.instance.id).into());
|
||||
}
|
||||
};
|
||||
|
||||
crate::install::install_pack_to_existing_instance(
|
||||
metadata.instance.id,
|
||||
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
|
||||
project_id,
|
||||
version_id,
|
||||
title: metadata.instance.name.clone(),
|
||||
icon_url: None,
|
||||
},
|
||||
post_install_edit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn unmanaged_pack_error(instance_id: &str) -> crate::ErrorKind {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {instance_id} is not a managed Modrinth pack, or has been disconnected."
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_instance_display_info(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<instance_rows::InstanceDisplayInfo> {
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string()).into()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
use super::content::get_projects;
|
||||
use crate::server_address::ServerAddress;
|
||||
use crate::state::{
|
||||
Credentials, InstanceInstallStage, InstanceLink, ProcessMetadata, Settings,
|
||||
State,
|
||||
};
|
||||
use crate::util::fetch;
|
||||
use crate::util::io::IOError;
|
||||
use crate::util::mojang::mojang_service_url;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub use crate::launcher::jvm_args::{GcLaunchIntent, GcLaunchReport};
|
||||
|
||||
const DEFAULT_LAUNCH_PREPARATION_TIMEOUT: u64 = 60;
|
||||
const MIN_LAUNCH_PREPARATION_TIMEOUT: u64 = 30;
|
||||
const MAX_LAUNCH_PREPARATION_TIMEOUT: u64 = 600;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum QuickPlayType {
|
||||
None,
|
||||
Singleplayer(String),
|
||||
Server(ServerAddress),
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn run(
|
||||
instance_id: &str,
|
||||
quick_play_type: QuickPlayType,
|
||||
offline_mode: bool,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
run_with_extra_launch_args(instance_id, quick_play_type, offline_mode, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn run_with_extra_launch_args(
|
||||
instance_id: &str,
|
||||
quick_play_type: QuickPlayType,
|
||||
offline_mode: bool,
|
||||
extra_launch_args: Option<Vec<String>>,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
Ok(run_with_extra_launch_args_inner(
|
||||
instance_id,
|
||||
quick_play_type,
|
||||
offline_mode,
|
||||
extra_launch_args,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.0)
|
||||
}
|
||||
|
||||
/// Like [`run_with_extra_launch_args`], but additionally resolves a GC-args
|
||||
/// intent against the actual JVM and reports what was actually used (useful
|
||||
/// for surfacing strategy fallback / flag pruning to the user).
|
||||
#[tracing::instrument]
|
||||
pub async fn run_with_extra_launch_args_with_gc(
|
||||
instance_id: &str,
|
||||
quick_play_type: QuickPlayType,
|
||||
offline_mode: bool,
|
||||
extra_launch_args: Option<Vec<String>>,
|
||||
gc_intent: Option<GcLaunchIntent>,
|
||||
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
|
||||
run_with_extra_launch_args_inner(
|
||||
instance_id,
|
||||
quick_play_type,
|
||||
offline_mode,
|
||||
extra_launch_args,
|
||||
gc_intent,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_with_extra_launch_args_inner(
|
||||
instance_id: &str,
|
||||
quick_play_type: QuickPlayType,
|
||||
offline_mode: bool,
|
||||
extra_launch_args: Option<Vec<String>>,
|
||||
gc_intent: Option<GcLaunchIntent>,
|
||||
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
|
||||
let state = State::get().await?;
|
||||
let launch_preparation_timeout =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.and_then(|context| context.launch_overrides.launch_preparation_timeout)
|
||||
.unwrap_or(DEFAULT_LAUNCH_PREPARATION_TIMEOUT)
|
||||
.clamp(
|
||||
MIN_LAUNCH_PREPARATION_TIMEOUT,
|
||||
MAX_LAUNCH_PREPARATION_TIMEOUT,
|
||||
);
|
||||
let default_account = if offline_mode {
|
||||
Credentials::get_offline_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(
|
||||
"Offline mode requires an offline Minecraft account"
|
||||
.to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?
|
||||
} else {
|
||||
Credentials::get_default_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?
|
||||
};
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(launch_preparation_timeout),
|
||||
run_credentials(
|
||||
instance_id,
|
||||
&default_account,
|
||||
quick_play_type,
|
||||
offline_mode,
|
||||
extra_launch_args,
|
||||
gc_intent,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::LauncherError(
|
||||
format!(
|
||||
"Minecraft launch preparation timed out after {launch_preparation_timeout} seconds"
|
||||
),
|
||||
)
|
||||
.as_error()
|
||||
})?
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(credentials))]
|
||||
async fn run_credentials(
|
||||
instance_id: &str,
|
||||
credentials: &Credentials,
|
||||
quick_play_type: QuickPlayType,
|
||||
offline_mode: bool,
|
||||
extra_launch_args: Option<Vec<String>>,
|
||||
gc_intent: Option<GcLaunchIntent>,
|
||||
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
|
||||
let state = State::get().await?;
|
||||
let settings = Settings::get(&state.pool).await?;
|
||||
let context =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to run a nonexistent instance {instance_id}!"
|
||||
))
|
||||
})?;
|
||||
|
||||
if offline_mode
|
||||
&& context.instance.install_stage != InstanceInstallStage::Installed
|
||||
{
|
||||
return Err(crate::ErrorKind::LauncherError(
|
||||
"Offline mode can only launch fully downloaded instances"
|
||||
.to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
|
||||
let pre_launch_hooks = context
|
||||
.launch_overrides
|
||||
.hooks
|
||||
.pre_launch
|
||||
.as_ref()
|
||||
.or(settings.hooks.pre_launch.as_ref())
|
||||
.filter(|hook_command| !hook_command.is_empty());
|
||||
if let Some(hook) = pre_launch_hooks {
|
||||
let mut cmd = shlex::split(hook)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Invalid pre-launch command: {hook}",
|
||||
))
|
||||
})?
|
||||
.into_iter();
|
||||
|
||||
if let Some(command) = cmd.next() {
|
||||
let full_path = crate::util::io::canonicalize(
|
||||
state.directories.resolve_game_dir(
|
||||
&context.instance.path,
|
||||
context.instance.game_dir_override.as_deref(),
|
||||
),
|
||||
)?;
|
||||
let mut command = Command::new(command);
|
||||
command.args(cmd).current_dir(&full_path).kill_on_drop(true);
|
||||
let result = command
|
||||
.spawn()
|
||||
.map_err(|e| IOError::with_path(e, &full_path))?
|
||||
.wait()
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
|
||||
if !result.success() {
|
||||
return Err(crate::ErrorKind::LauncherError(format!(
|
||||
"Non-zero exit code for pre-launch hook: {}",
|
||||
result.code().unwrap_or(-1)
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let java_args = if let Some(extra_launch_args) = extra_launch_args {
|
||||
extra_launch_args
|
||||
} else {
|
||||
context
|
||||
.launch_overrides
|
||||
.extra_launch_args
|
||||
.clone()
|
||||
.unwrap_or(settings.extra_launch_args)
|
||||
};
|
||||
let wrapper = context
|
||||
.launch_overrides
|
||||
.hooks
|
||||
.wrapper
|
||||
.clone()
|
||||
.or(settings.hooks.wrapper)
|
||||
.filter(|hook_command| !hook_command.is_empty());
|
||||
let mut memory = context.launch_overrides.memory.unwrap_or(settings.memory);
|
||||
let resolution = context
|
||||
.launch_overrides
|
||||
.game_resolution
|
||||
.unwrap_or(settings.game_resolution);
|
||||
let maximize_window = context
|
||||
.launch_overrides
|
||||
.maximize_window
|
||||
.unwrap_or(settings.maximize_window);
|
||||
let env_args = context
|
||||
.launch_overrides
|
||||
.custom_env_vars
|
||||
.clone()
|
||||
.unwrap_or(settings.custom_env_vars);
|
||||
let post_exit_hook = context
|
||||
.launch_overrides
|
||||
.hooks
|
||||
.post_exit
|
||||
.clone()
|
||||
.or(settings.hooks.post_exit)
|
||||
.filter(|hook_command| !hook_command.is_empty());
|
||||
|
||||
let mut mc_set_options: Vec<(String, String)> = vec![];
|
||||
if let Some(fullscreen) = context.launch_overrides.force_fullscreen {
|
||||
mc_set_options.push(("fullscreen".to_string(), fullscreen.to_string()));
|
||||
} else if settings.force_fullscreen {
|
||||
mc_set_options.push(("fullscreen".to_string(), "true".to_string()));
|
||||
}
|
||||
|
||||
if credentials.is_microsoft()
|
||||
&& let Some(project_id) = server_play_project_id(&context.link)
|
||||
&& !project_id.trim().is_empty()
|
||||
{
|
||||
let server_id = uuid::Uuid::new_v4().to_string();
|
||||
let join_url = mojang_service_url(
|
||||
"https://sessionserver.mojang.com/session/minecraft/join",
|
||||
state.mojang_auth_use_mirror(),
|
||||
);
|
||||
let join_result = fetch::INSECURE_REQWEST_CLIENT
|
||||
.post(join_url.as_ref())
|
||||
.json(&json!({
|
||||
"accessToken": &credentials.access_token,
|
||||
"selectedProfile": credentials.offline_profile.id.simple().to_string(),
|
||||
"serverId": &server_id,
|
||||
}))
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match join_result {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let result = fetch::post_json(
|
||||
concat!(
|
||||
env!("MODRINTH_API_BASE_URL"),
|
||||
"analytics/minecraft-server-play"
|
||||
),
|
||||
json!({
|
||||
"project_id": project_id,
|
||||
"username": &credentials.offline_profile.name,
|
||||
"server_id": &server_id,
|
||||
}),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Tracked server play for '{project_id}' in analytics"
|
||||
)
|
||||
}
|
||||
Err(err) => warn!("Failed to report server play: {err:?}"),
|
||||
}
|
||||
}
|
||||
Ok(resp) => warn!(
|
||||
"Failed to join Mojang session server: HTTP {}",
|
||||
resp.status()
|
||||
),
|
||||
Err(err) => warn!("Failed to join Mojang session server: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
if offline_mode {
|
||||
crate::minecraft_skins::flush_pending_skin_change_for_profile(
|
||||
credentials.offline_profile.id,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
crate::minecraft_skins::flush_pending_skin_change().await?;
|
||||
}
|
||||
if memory.optimize_before_launch
|
||||
&& crate::api::memory::optimization_supported()
|
||||
{
|
||||
tracing::info!("Optimizing memory before launching Minecraft");
|
||||
crate::api::memory::optimize().await?;
|
||||
}
|
||||
|
||||
if memory.automatic {
|
||||
let instance_path = state.directories.resolve_game_dir(
|
||||
&context.instance.path,
|
||||
context.instance.game_dir_override.as_deref(),
|
||||
);
|
||||
memory.maximum = crate::api::jre::automatic_memory_max_mb_for_instance(
|
||||
&instance_path,
|
||||
matches!(
|
||||
context.applied_content_set.loader,
|
||||
crate::state::ModLoader::Forge
|
||||
| crate::state::ModLoader::Fabric
|
||||
| crate::state::ModLoader::Quilt
|
||||
| crate::state::ModLoader::NeoForge
|
||||
| crate::state::ModLoader::Cleanroom
|
||||
| crate::state::ModLoader::LiteLoader
|
||||
| crate::state::ModLoader::LegacyFabric
|
||||
| crate::state::ModLoader::Babric
|
||||
),
|
||||
);
|
||||
tracing::info!(
|
||||
"Automatically allocated {} MiB of memory",
|
||||
memory.maximum
|
||||
);
|
||||
}
|
||||
|
||||
let mut gc_report: Option<GcLaunchReport> = None;
|
||||
let process = crate::launcher::launch_minecraft(
|
||||
&java_args,
|
||||
&env_args,
|
||||
&mc_set_options,
|
||||
&wrapper,
|
||||
&memory,
|
||||
&resolution,
|
||||
maximize_window,
|
||||
credentials,
|
||||
post_exit_hook,
|
||||
&context,
|
||||
gc_intent,
|
||||
&mut gc_report,
|
||||
quick_play_type,
|
||||
offline_mode,
|
||||
)
|
||||
.await?;
|
||||
Ok((process, gc_report))
|
||||
}
|
||||
|
||||
fn server_play_project_id(link: &InstanceLink) -> Option<&String> {
|
||||
match link {
|
||||
InstanceLink::ServerProject { project_id }
|
||||
| InstanceLink::ServerProjectModpack {
|
||||
server_project_id: project_id,
|
||||
..
|
||||
} => Some(project_id),
|
||||
InstanceLink::Unmanaged
|
||||
| InstanceLink::ModrinthModpack { .. }
|
||||
| InstanceLink::CurseForgeModpack { .. }
|
||||
| InstanceLink::ImportedModpack { .. }
|
||||
| InstanceLink::SharedInstance { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn modrinth_pack_version_id(link: &InstanceLink) -> Option<&str> {
|
||||
match link {
|
||||
InstanceLink::ModrinthModpack { version_id, .. }
|
||||
| InstanceLink::ServerProjectModpack {
|
||||
content_version_id: version_id,
|
||||
..
|
||||
} => Some(version_id),
|
||||
InstanceLink::Unmanaged
|
||||
| InstanceLink::ServerProject { .. }
|
||||
| InstanceLink::CurseForgeModpack { .. }
|
||||
| InstanceLink::ImportedModpack { .. }
|
||||
| InstanceLink::SharedInstance { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn playtime_api_url(base_url: &str) -> String {
|
||||
format!("{}/analytics/playtime", base_url.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
pub async fn kill(instance_id: &str) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let processes =
|
||||
crate::api::process::get_by_instance_id(instance_id).await?;
|
||||
|
||||
for process in processes {
|
||||
state.process_manager.kill(process.uuid).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn try_update_playtime_by_instance_id(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let context =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to update playtime for nonexistent instance {instance_id}!"
|
||||
))
|
||||
})?;
|
||||
let updated_recent_playtime = context.instance.recent_time_played;
|
||||
let res = if updated_recent_playtime > 0 {
|
||||
let modrinth_pack_version_id = modrinth_pack_version_id(&context.link);
|
||||
let playtime_update_json = json!({
|
||||
"seconds": updated_recent_playtime,
|
||||
"loader": context.applied_content_set.loader.as_str(),
|
||||
"game_version": &context.applied_content_set.game_version,
|
||||
"parent": modrinth_pack_version_id,
|
||||
});
|
||||
let mut hashmap: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
|
||||
for (_, project) in get_projects(instance_id, None).await? {
|
||||
if let Some(metadata) = project.modrinth {
|
||||
hashmap.insert(
|
||||
metadata.version_id.to_string(),
|
||||
playtime_update_json.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let playtime_url = playtime_api_url(env!("MODRINTH_API_BASE_URL"));
|
||||
fetch::post_json(
|
||||
&playtime_url,
|
||||
serde_json::to_value(hashmap)?,
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if res.is_ok() {
|
||||
crate::state::instances::commands::mark_instance_playtime_submitted(
|
||||
&context.instance.id,
|
||||
updated_recent_playtime,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{modrinth_pack_version_id, playtime_api_url};
|
||||
use crate::state::InstanceLink;
|
||||
|
||||
#[test]
|
||||
fn playtime_parent_requires_an_explicit_modrinth_link() {
|
||||
let modrinth = InstanceLink::ModrinthModpack {
|
||||
project_id: "project".to_string(),
|
||||
version_id: "version".to_string(),
|
||||
};
|
||||
let curseforge = InstanceLink::CurseForgeModpack {
|
||||
project_id: "123".to_string(),
|
||||
version_id: "456".to_string(),
|
||||
};
|
||||
let imported = InstanceLink::ImportedModpack {
|
||||
project_id: Some("legacy-project".to_string()),
|
||||
version_id: Some("legacy-version".to_string()),
|
||||
name: None,
|
||||
version_number: None,
|
||||
filename: None,
|
||||
};
|
||||
|
||||
assert_eq!(modrinth_pack_version_id(&modrinth), Some("version"));
|
||||
assert_eq!(modrinth_pack_version_id(&curseforge), None);
|
||||
assert_eq!(modrinth_pack_version_id(&imported), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playtime_url_has_a_single_path_separator() {
|
||||
assert_eq!(
|
||||
playtime_api_url("https://api.modrinth.com"),
|
||||
"https://api.modrinth.com/analytics/playtime"
|
||||
);
|
||||
assert_eq!(
|
||||
playtime_api_url("https://api.modrinth.com/"),
|
||||
"https://api.modrinth.com/analytics/playtime"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::state::instances::adapters::sqlite::content_rows;
|
||||
use crate::state::{
|
||||
ContentSourceKind, InstanceLink, InstanceUpgradeAction,
|
||||
InstanceUpgradeFixedConstraint, InstanceUpgradePlan,
|
||||
InstanceUpgradeResolution, InstanceUpgradeResolutionBatchResult,
|
||||
InstanceUpgradeResolutionResult, InstanceUpgradeSolutionChoice,
|
||||
InstanceUpgradeSolutionKind, State,
|
||||
};
|
||||
|
||||
use crate::install::{
|
||||
InstallJobSnapshot, InstanceUpgradeExecution, InstanceUpgradeWatchBaseline,
|
||||
SharedUpgradeMode,
|
||||
};
|
||||
|
||||
struct StoredUpgradePlanState {
|
||||
plan: InstanceUpgradePlan,
|
||||
validation: crate::state::instances::commands::UpgradePlanRuntimeValidation,
|
||||
execution_started: bool,
|
||||
}
|
||||
|
||||
type StoredUpgradePlan = Arc<Mutex<StoredUpgradePlanState>>;
|
||||
|
||||
static INSTANCE_UPGRADE_PLANS: LazyLock<DashMap<String, StoredUpgradePlan>> =
|
||||
LazyLock::new(DashMap::new);
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn plan_instance_upgrade(
|
||||
instance_id: &str,
|
||||
target_environment: crate::state::InstanceUpgradeEnvironment,
|
||||
) -> crate::Result<InstanceUpgradePlan> {
|
||||
let state = State::get().await?;
|
||||
let creation_watch =
|
||||
state.file_watcher.content_watch_snapshot(instance_id).await;
|
||||
let (plan, source) = crate::state::instances::commands::create_instance_upgrade_plan_with_source(
|
||||
instance_id,
|
||||
target_environment,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
let mut validation =
|
||||
crate::state::instances::commands::UpgradePlanRuntimeValidation::new(
|
||||
source,
|
||||
instance_id,
|
||||
creation_watch,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
validation.validate(&plan, &state).await?;
|
||||
INSTANCE_UPGRADE_PLANS.insert(
|
||||
plan.id.clone(),
|
||||
Arc::new(Mutex::new(StoredUpgradePlanState {
|
||||
plan: plan.clone(),
|
||||
validation,
|
||||
execution_started: false,
|
||||
})),
|
||||
);
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_instance_upgrade_plan(
|
||||
plan_id: &str,
|
||||
) -> crate::Result<InstanceUpgradePlan> {
|
||||
let state = State::get().await?;
|
||||
let handle = stored_plan_handle(plan_id)?;
|
||||
let mut stored = handle.lock().await;
|
||||
if let Err(error) = ensure_current_revision(&mut stored, &state).await {
|
||||
drop(stored);
|
||||
INSTANCE_UPGRADE_PLANS.remove(plan_id);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(stored.plan.clone())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_instance_upgrade_resolution(
|
||||
plan_id: &str,
|
||||
resolution: InstanceUpgradeResolution,
|
||||
) -> crate::Result<InstanceUpgradePlan> {
|
||||
let state = State::get().await?;
|
||||
let handle = stored_plan_handle(plan_id)?;
|
||||
let mut stored = handle.lock().await;
|
||||
let source = ensure_current_revision(&mut stored, &state).await?;
|
||||
let mut plan = stored.plan.clone();
|
||||
let item = plan
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|item| item.content_id == resolution.content_id)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Upgrade plan has no content item {}",
|
||||
resolution.content_id
|
||||
))
|
||||
})?;
|
||||
item.resolution = resolution;
|
||||
let (kind, constraints) = selected_kind_and_constraints(&plan);
|
||||
crate::state::instances::commands::recompute_instance_upgrade_plan_from_source(
|
||||
&mut plan,
|
||||
&constraints,
|
||||
kind,
|
||||
source,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
stored.plan = plan.clone();
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(resolutions))]
|
||||
pub async fn update_instance_upgrade_resolutions(
|
||||
plan_id: &str,
|
||||
resolutions: Vec<InstanceUpgradeResolution>,
|
||||
) -> crate::Result<InstanceUpgradeResolutionBatchResult> {
|
||||
let state = State::get().await?;
|
||||
let handle = stored_plan_handle(plan_id)?;
|
||||
let mut stored = handle.lock().await;
|
||||
let source = ensure_current_revision(&mut stored, &state).await?;
|
||||
let requested_count = resolutions.len();
|
||||
let (requests, mut skipped) = normalize_batch_resolutions(resolutions);
|
||||
let mut working_plan = stored.plan.clone();
|
||||
let mut applied = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut pending = vec![requests];
|
||||
|
||||
while let Some(chunk) = pending.pop() {
|
||||
let mut applicable = Vec::new();
|
||||
for resolution in chunk {
|
||||
if !resolution_is_applicable(&working_plan, &resolution) {
|
||||
skipped.push(InstanceUpgradeResolutionResult {
|
||||
content_id: resolution.content_id,
|
||||
code: Some("no_longer_applicable".to_string()),
|
||||
message: Some(
|
||||
"Resolution is no longer applicable".to_string(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
applicable.push(resolution);
|
||||
}
|
||||
}
|
||||
if applicable.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut trial = working_plan.clone();
|
||||
apply_resolutions(&mut trial, &applicable)?;
|
||||
let (kind, constraints) = selected_kind_and_constraints(&trial);
|
||||
match crate::state::instances::commands::recompute_instance_upgrade_plan_from_source(
|
||||
&mut trial,
|
||||
&constraints,
|
||||
kind,
|
||||
source.clone(),
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
applied.extend(applicable.iter().map(|resolution| {
|
||||
InstanceUpgradeResolutionResult {
|
||||
content_id: resolution.content_id.clone(),
|
||||
code: None,
|
||||
message: None,
|
||||
}
|
||||
}));
|
||||
working_plan = trial;
|
||||
}
|
||||
Err(error) if applicable.len() == 1 => {
|
||||
failed.push(InstanceUpgradeResolutionResult {
|
||||
content_id: applicable[0].content_id.clone(),
|
||||
code: Some("resolution_failed".to_string()),
|
||||
message: Some(error.to_string()),
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
let midpoint = applicable.len() / 2;
|
||||
let right = applicable[midpoint..].to_vec();
|
||||
let left = applicable[..midpoint].to_vec();
|
||||
pending.push(right);
|
||||
pending.push(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !applied.is_empty() {
|
||||
stored.plan = working_plan.clone();
|
||||
}
|
||||
Ok(InstanceUpgradeResolutionBatchResult {
|
||||
plan: working_plan,
|
||||
requested_count,
|
||||
applied,
|
||||
skipped,
|
||||
failed,
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn reset_instance_upgrade_resolution(
|
||||
plan_id: &str,
|
||||
content_id: &str,
|
||||
) -> crate::Result<InstanceUpgradePlan> {
|
||||
let state = State::get().await?;
|
||||
let handle = stored_plan_handle(plan_id)?;
|
||||
let mut stored = handle.lock().await;
|
||||
let source = ensure_current_revision(&mut stored, &state).await?;
|
||||
let mut plan = stored.plan.clone();
|
||||
let item = plan
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|item| item.content_id == content_id)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Upgrade plan has no content item {content_id}"
|
||||
))
|
||||
})?;
|
||||
item.resolution = automatic_resolution(item);
|
||||
let (kind, constraints) = selected_kind_and_constraints(&plan);
|
||||
crate::state::instances::commands::recompute_instance_upgrade_plan_from_source(
|
||||
&mut plan,
|
||||
&constraints,
|
||||
kind,
|
||||
source,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
stored.plan = plan.clone();
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn normalize_batch_resolutions(
|
||||
resolutions: Vec<InstanceUpgradeResolution>,
|
||||
) -> (
|
||||
Vec<InstanceUpgradeResolution>,
|
||||
Vec<InstanceUpgradeResolutionResult>,
|
||||
) {
|
||||
let mut by_content_id = HashMap::new();
|
||||
let mut skipped = Vec::new();
|
||||
for resolution in resolutions {
|
||||
if by_content_id
|
||||
.insert(resolution.content_id.clone(), resolution.clone())
|
||||
.is_some()
|
||||
{
|
||||
skipped.push(InstanceUpgradeResolutionResult {
|
||||
content_id: resolution.content_id,
|
||||
code: Some("duplicate_request".to_string()),
|
||||
message: Some(
|
||||
"Duplicate request replaced by its last value".to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut normalized = by_content_id.into_values().collect::<Vec<_>>();
|
||||
normalized.sort_by(|left, right| left.content_id.cmp(&right.content_id));
|
||||
(normalized, skipped)
|
||||
}
|
||||
|
||||
fn resolution_is_applicable(
|
||||
plan: &InstanceUpgradePlan,
|
||||
resolution: &InstanceUpgradeResolution,
|
||||
) -> bool {
|
||||
plan.items.iter().any(|item| {
|
||||
item.content_id == resolution.content_id
|
||||
&& item.resolution != *resolution
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_resolutions(
|
||||
plan: &mut InstanceUpgradePlan,
|
||||
resolutions: &[InstanceUpgradeResolution],
|
||||
) -> crate::Result<()> {
|
||||
for resolution in resolutions {
|
||||
let item = plan
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|item| item.content_id == resolution.content_id)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Upgrade plan has no content item {}",
|
||||
resolution.content_id
|
||||
))
|
||||
})?;
|
||||
item.resolution = resolution.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn automatic_resolution(
|
||||
item: &crate::state::InstanceUpgradeItem,
|
||||
) -> InstanceUpgradeResolution {
|
||||
let action = if matches!(
|
||||
item.status,
|
||||
crate::state::InstanceUpgradeItemStatus::Unidentified
|
||||
| crate::state::InstanceUpgradeItemStatus::UnsupportedContentType
|
||||
) {
|
||||
InstanceUpgradeAction::Keep
|
||||
} else {
|
||||
InstanceUpgradeAction::Upgrade
|
||||
};
|
||||
InstanceUpgradeResolution {
|
||||
content_id: item.content_id.clone(),
|
||||
action,
|
||||
allow_prerelease: false,
|
||||
confirmed_prerelease_dependencies: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn select_instance_upgrade_solution(
|
||||
plan_id: &str,
|
||||
choice: InstanceUpgradeSolutionChoice,
|
||||
) -> crate::Result<InstanceUpgradePlan> {
|
||||
let state = State::get().await?;
|
||||
let handle = stored_plan_handle(plan_id)?;
|
||||
let mut stored = handle.lock().await;
|
||||
ensure_current_revision(&mut stored, &state).await?;
|
||||
let mut plan = stored.plan.clone();
|
||||
plan.selected_solution = match choice {
|
||||
InstanceUpgradeSolutionChoice::Newest => plan.newest_solution.clone(),
|
||||
InstanceUpgradeSolutionChoice::MinimalChange => {
|
||||
plan.minimal_change_solution.clone()
|
||||
}
|
||||
InstanceUpgradeSolutionChoice::Custom => Some(
|
||||
plan.selected_solution
|
||||
.clone()
|
||||
.filter(|solution| {
|
||||
solution.kind == InstanceUpgradeSolutionKind::Custom
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"No custom upgrade solution has been resolved"
|
||||
.to_string(),
|
||||
)
|
||||
})?,
|
||||
),
|
||||
};
|
||||
plan.dependency_changes = plan
|
||||
.selected_solution
|
||||
.as_ref()
|
||||
.map(|solution| solution.dependency_changes.clone())
|
||||
.unwrap_or_default();
|
||||
stored.plan = plan.clone();
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn resolve_custom_instance_upgrade_solution(
|
||||
plan_id: &str,
|
||||
fixed_constraints: Vec<InstanceUpgradeFixedConstraint>,
|
||||
) -> crate::Result<InstanceUpgradePlan> {
|
||||
let state = State::get().await?;
|
||||
let handle = stored_plan_handle(plan_id)?;
|
||||
let mut stored = handle.lock().await;
|
||||
let source = ensure_current_revision(&mut stored, &state).await?;
|
||||
let mut plan = stored.plan.clone();
|
||||
validate_fixed_constraints(&plan, &fixed_constraints)?;
|
||||
crate::state::instances::commands::recompute_instance_upgrade_plan_from_source(
|
||||
&mut plan,
|
||||
&fixed_constraints,
|
||||
InstanceUpgradeSolutionKind::Custom,
|
||||
source,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
plan.custom_constraints = fixed_constraints;
|
||||
stored.plan = plan.clone();
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn execute_instance_upgrade(
|
||||
plan_id: &str,
|
||||
create_full_backup: bool,
|
||||
shared_upgrade_mode: SharedUpgradeMode,
|
||||
display_names: crate::install::InstanceUpgradeDisplayNames,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
let state = State::get().await?;
|
||||
let handle = stored_plan_handle(plan_id)?;
|
||||
let mut stored = handle.lock().await;
|
||||
if stored.execution_started {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Upgrade plan execution has already started".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let _execution_guard =
|
||||
state.lock_instance_content(&stored.plan.instance_id).await;
|
||||
let current_revision = content_rows::get_applied_content_set(
|
||||
&stored.plan.instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance has no applied content set".to_string(),
|
||||
)
|
||||
})?
|
||||
.revision;
|
||||
ensure_instance_upgrade_revision(
|
||||
stored.plan.source_revision,
|
||||
current_revision,
|
||||
)?;
|
||||
if !stored.plan.blocking_issues.is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Upgrade plan still has blocking issues".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let solution = stored.plan.selected_solution.clone().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Upgrade plan has no selected solution".to_string(),
|
||||
)
|
||||
})?;
|
||||
let metadata = crate::state::instances::commands::get_instance_metadata(
|
||||
&stored.plan.instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
if !matches!(metadata.link, InstanceLink::Unmanaged)
|
||||
|| metadata.applied_content_set.source_kind != ContentSourceKind::Local
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Only Local unmanaged instances can use upgrade execution"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if state
|
||||
.process_manager
|
||||
.get_all()
|
||||
.iter()
|
||||
.any(|process| process.instance_id == stored.plan.instance_id)
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance is currently running".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let resolved_target_loader =
|
||||
crate::launcher::get_loader_version_from_profile(
|
||||
&stored.plan.target_environment.game_version,
|
||||
stored.plan.target_environment.mod_loader,
|
||||
stored.plan.target_environment.mod_loader_version.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
if stored.plan.target_environment.mod_loader
|
||||
!= crate::state::ModLoader::Vanilla
|
||||
&& resolved_target_loader.is_none()
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Target loader version is no longer available".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
ensure_upgrade_disk_space(
|
||||
&metadata,
|
||||
&stored.plan.source_files,
|
||||
create_full_backup,
|
||||
shared_upgrade_mode,
|
||||
&state,
|
||||
)?;
|
||||
ensure_upgrade_target_writable(
|
||||
&state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&metadata.instance.path),
|
||||
)
|
||||
.await?;
|
||||
crate::state::instances::commands::validate_instance_upgrade_plan_source(
|
||||
&stored.plan,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
let source_watch = state
|
||||
.file_watcher
|
||||
.content_watch_snapshot(&stored.plan.instance_id)
|
||||
.await
|
||||
.map(|snapshot| InstanceUpgradeWatchBaseline {
|
||||
epoch: snapshot.epoch,
|
||||
generation: snapshot.generation,
|
||||
dirty_paths: snapshot.dirty_paths.into_iter().collect(),
|
||||
});
|
||||
if crate::install::store::list(false, &state)
|
||||
.await?
|
||||
.into_iter()
|
||||
.any(|job| {
|
||||
!job.status.is_finished()
|
||||
&& job.instance_id.as_deref()
|
||||
== Some(stored.plan.instance_id.as_str())
|
||||
})
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance already has an active install job".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let instance_id = stored.plan.instance_id.clone();
|
||||
let mut target_environment = stored.plan.target_environment.clone();
|
||||
if let Some(loader) = resolved_target_loader {
|
||||
target_environment.mod_loader_version = Some(loader.id);
|
||||
}
|
||||
let mut warnings = stored.plan.warnings.clone();
|
||||
for warning in &solution.warnings {
|
||||
if !warnings.contains(warning) {
|
||||
warnings.push(warning.clone());
|
||||
}
|
||||
}
|
||||
let execution = InstanceUpgradeExecution {
|
||||
source_revision: stored.plan.source_revision,
|
||||
source_files: stored.plan.source_files.clone(),
|
||||
source_environment: stored.plan.source_environment.clone(),
|
||||
target_environment,
|
||||
items: stored.plan.items.clone(),
|
||||
solution,
|
||||
warnings,
|
||||
source_watch,
|
||||
};
|
||||
stored.execution_started = true;
|
||||
let result = crate::install::upgrade_unmanaged_instance(
|
||||
instance_id,
|
||||
plan_id.to_string(),
|
||||
execution,
|
||||
create_full_backup,
|
||||
shared_upgrade_mode,
|
||||
display_names,
|
||||
)
|
||||
.await;
|
||||
if result.is_err() {
|
||||
stored.execution_started = false;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn get_instance_post_upgrade_notice(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Option<crate::state::InstancePostUpgradeNotice>> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::get_instance_post_upgrade_notice(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn dismiss_instance_post_upgrade_notice(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
crate::state::instances::commands::dismiss_instance_post_upgrade_notice(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn ensure_upgrade_disk_space(
|
||||
metadata: &crate::state::InstanceMetadata,
|
||||
source_files: &[crate::state::InstanceUpgradeSourceFile],
|
||||
create_full_backup: bool,
|
||||
shared_upgrade_mode: SharedUpgradeMode,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let instance_path = state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&metadata.instance.path);
|
||||
let canonical = crate::util::io::canonicalize(&instance_path)?;
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
let available = disks
|
||||
.iter()
|
||||
.filter(|disk| canonical.starts_with(disk.mount_point()))
|
||||
.max_by_key(|disk| disk.mount_point().as_os_str().len())
|
||||
.map(sysinfo::Disk::available_space);
|
||||
let Some(available) = available else {
|
||||
return Ok(());
|
||||
};
|
||||
let source_size = source_files
|
||||
.iter()
|
||||
.fold(0_u64, |total, file| total.saturating_add(file.size));
|
||||
let copies = 1_u64
|
||||
+ u64::from(
|
||||
create_full_backup
|
||||
&& shared_upgrade_mode == SharedUpgradeMode::Direct,
|
||||
);
|
||||
let required = source_size
|
||||
.saturating_mul(copies)
|
||||
.saturating_add(source_size / 10);
|
||||
if available < required {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"Not enough free disk space for upgrade staging: need {required} bytes, have {available} bytes"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_upgrade_target_writable(
|
||||
path: &std::path::Path,
|
||||
) -> crate::Result<()> {
|
||||
let probe = path.join(format!(
|
||||
".instance-upgrade-write-test-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&probe)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"Upgrade target is not writable: {error}"
|
||||
))
|
||||
})?;
|
||||
drop(file);
|
||||
tokio::fs::remove_file(&probe).await.map_err(|error| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"Upgrade write probe could not be removed: {error}"
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn stored_plan_handle(plan_id: &str) -> crate::Result<StoredUpgradePlan> {
|
||||
INSTANCE_UPGRADE_PLANS
|
||||
.get(plan_id)
|
||||
.map(|entry| Arc::clone(entry.value()))
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"The instance upgrade plan has expired".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_current_revision(
|
||||
stored: &mut StoredUpgradePlanState,
|
||||
state: &State,
|
||||
) -> crate::Result<crate::state::instances::commands::ReadOnlyUpgradeSource> {
|
||||
let current_revision = content_rows::get_applied_content_set(
|
||||
&stored.plan.instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance has no applied content set".to_string(),
|
||||
)
|
||||
})?
|
||||
.revision;
|
||||
if let Err(error) = ensure_instance_upgrade_revision(
|
||||
stored.plan.source_revision,
|
||||
current_revision,
|
||||
) {
|
||||
return Err(error);
|
||||
}
|
||||
stored.validation.validate(&stored.plan, state).await
|
||||
}
|
||||
|
||||
fn ensure_instance_upgrade_revision(
|
||||
planned_revision: u64,
|
||||
current_revision: u64,
|
||||
) -> crate::Result<()> {
|
||||
if planned_revision == current_revision {
|
||||
return Ok(());
|
||||
}
|
||||
Err(crate::ErrorKind::StaleInstanceUpgradePlan {
|
||||
planned_revision,
|
||||
current_revision,
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
fn selected_kind_and_constraints(
|
||||
plan: &InstanceUpgradePlan,
|
||||
) -> (
|
||||
InstanceUpgradeSolutionKind,
|
||||
Vec<InstanceUpgradeFixedConstraint>,
|
||||
) {
|
||||
let Some(solution) = plan.selected_solution.as_ref() else {
|
||||
return (InstanceUpgradeSolutionKind::Newest, Vec::new());
|
||||
};
|
||||
if solution.kind != InstanceUpgradeSolutionKind::Custom {
|
||||
return (solution.kind, Vec::new());
|
||||
}
|
||||
(
|
||||
InstanceUpgradeSolutionKind::Custom,
|
||||
plan.custom_constraints.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_fixed_constraints(
|
||||
plan: &InstanceUpgradePlan,
|
||||
constraints: &[InstanceUpgradeFixedConstraint],
|
||||
) -> crate::Result<()> {
|
||||
let mut seen = HashMap::new();
|
||||
for constraint in constraints {
|
||||
if let Some(previous) = seen.insert(
|
||||
constraint.content_id.as_str(),
|
||||
constraint.version_id.as_str(),
|
||||
) && previous != constraint.version_id.as_str()
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Custom upgrade constraints select multiple versions for content {}",
|
||||
constraint.content_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let root_exists = plan.items.iter().any(|item| {
|
||||
!item.auto_dependency
|
||||
&& item.content_id == constraint.content_id
|
||||
&& item.provider == Some(constraint.provider)
|
||||
&& item.project_id.as_deref()
|
||||
== Some(constraint.project_id.as_str())
|
||||
});
|
||||
if !root_exists {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Custom upgrade constraint does not match root content {} at {}:{}",
|
||||
constraint.content_id,
|
||||
constraint.provider.as_str(),
|
||||
constraint.project_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_fixed_constraint_preserves_content_id_through_serde() {
|
||||
let input = serde_json::json!({
|
||||
"contentId": "physical-iris",
|
||||
"provider": "modrinth",
|
||||
"projectId": "YL57xq9U",
|
||||
"versionId": "Rhzf61g1"
|
||||
});
|
||||
|
||||
let constraint: InstanceUpgradeFixedConstraint =
|
||||
serde_json::from_value(input).unwrap();
|
||||
let output = serde_json::to_value(constraint).unwrap();
|
||||
|
||||
assert_eq!(output["contentId"], "physical-iris");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_custom_constraints_are_rejected_before_provider_work() {
|
||||
let mut plan = empty_plan();
|
||||
plan.items.push(crate::state::InstanceUpgradeItem {
|
||||
content_id: "root".to_string(),
|
||||
relative_path: "mods/root.jar".to_string(),
|
||||
project_type: crate::state::ProjectType::Mod,
|
||||
provider: Some(crate::state::ContentProvider::Modrinth),
|
||||
project_id: Some("root".to_string()),
|
||||
current_release_id: Some("old".to_string()),
|
||||
current_enabled: true,
|
||||
auto_dependency: false,
|
||||
status: crate::state::InstanceUpgradeItemStatus::UpgradeAvailable,
|
||||
resolution: crate::state::InstanceUpgradeResolution {
|
||||
content_id: "root".to_string(),
|
||||
action: crate::state::InstanceUpgradeAction::Upgrade,
|
||||
allow_prerelease: false,
|
||||
confirmed_prerelease_dependencies: Vec::new(),
|
||||
},
|
||||
candidate_release_ids: vec!["one".to_string(), "two".to_string()],
|
||||
});
|
||||
let constraints = vec![
|
||||
InstanceUpgradeFixedConstraint {
|
||||
content_id: "root".to_string(),
|
||||
provider: crate::state::ContentProvider::Modrinth,
|
||||
project_id: "root".to_string(),
|
||||
version_id: "one".to_string(),
|
||||
},
|
||||
InstanceUpgradeFixedConstraint {
|
||||
content_id: "root".to_string(),
|
||||
provider: crate::state::ContentProvider::Modrinth,
|
||||
project_id: "root".to_string(),
|
||||
version_id: "two".to_string(),
|
||||
},
|
||||
];
|
||||
assert!(validate_fixed_constraints(&plan, &constraints).is_err());
|
||||
|
||||
let wrong_physical_root = vec![InstanceUpgradeFixedConstraint {
|
||||
content_id: "different-root".to_string(),
|
||||
provider: crate::state::ContentProvider::Modrinth,
|
||||
project_id: "root".to_string(),
|
||||
version_id: "one".to_string(),
|
||||
}];
|
||||
assert!(
|
||||
validate_fixed_constraints(&plan, &wrong_physical_root).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_upgrade_plan_revision_is_rejected() {
|
||||
assert!(ensure_instance_upgrade_revision(4, 4).is_ok());
|
||||
let error = ensure_instance_upgrade_revision(4, 5).unwrap_err();
|
||||
assert!(error.to_string().contains("planned revision 4"));
|
||||
assert!(error.to_string().contains("current revision 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_recompute_uses_only_explicitly_stored_constraints() {
|
||||
let mut plan = empty_plan();
|
||||
plan.custom_constraints = vec![InstanceUpgradeFixedConstraint {
|
||||
content_id: "a".to_string(),
|
||||
provider: crate::state::ContentProvider::Modrinth,
|
||||
project_id: "a".to_string(),
|
||||
version_id: "a-fixed".to_string(),
|
||||
}];
|
||||
plan.selected_solution = Some(crate::state::InstanceUpgradeSolution {
|
||||
kind: InstanceUpgradeSolutionKind::Custom,
|
||||
selections: vec![
|
||||
crate::state::InstanceUpgradeSelection {
|
||||
content_id: "a".to_string(),
|
||||
provider: Some(crate::state::ContentProvider::Modrinth),
|
||||
project_id: Some("a".to_string()),
|
||||
current_release_id: Some("a-old".to_string()),
|
||||
target_release_id: Some("a-fixed".to_string()),
|
||||
action: crate::state::InstanceUpgradeAction::Upgrade,
|
||||
enabled: true,
|
||||
},
|
||||
crate::state::InstanceUpgradeSelection {
|
||||
content_id: "b".to_string(),
|
||||
provider: Some(crate::state::ContentProvider::Modrinth),
|
||||
project_id: Some("b".to_string()),
|
||||
current_release_id: Some("b-old".to_string()),
|
||||
target_release_id: Some("b-auto".to_string()),
|
||||
action: crate::state::InstanceUpgradeAction::Upgrade,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
dependency_changes: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
});
|
||||
let (kind, constraints) = selected_kind_and_constraints(&plan);
|
||||
assert_eq!(kind, InstanceUpgradeSolutionKind::Custom);
|
||||
assert_eq!(constraints, plan.custom_constraints);
|
||||
assert_eq!(constraints.len(), 1);
|
||||
assert_eq!(constraints[0].project_id, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_requests_dedupe_by_content_with_last_value_and_stable_order() {
|
||||
let request = |content_id: &str, action| InstanceUpgradeResolution {
|
||||
content_id: content_id.to_string(),
|
||||
action,
|
||||
allow_prerelease: false,
|
||||
confirmed_prerelease_dependencies: Vec::new(),
|
||||
};
|
||||
let (normalized, skipped) = normalize_batch_resolutions(vec![
|
||||
request("b", InstanceUpgradeAction::Keep),
|
||||
request("a", InstanceUpgradeAction::Disable),
|
||||
request("b", InstanceUpgradeAction::Disable),
|
||||
]);
|
||||
assert_eq!(
|
||||
normalized
|
||||
.iter()
|
||||
.map(|item| item.content_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["a", "b"]
|
||||
);
|
||||
assert_eq!(normalized[1].action, InstanceUpgradeAction::Disable);
|
||||
assert_eq!(skipped.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_resolution_is_skipped_when_plan_already_has_requested_value() {
|
||||
let mut plan = empty_plan();
|
||||
plan.items.push(crate::state::InstanceUpgradeItem {
|
||||
content_id: "root".to_string(),
|
||||
relative_path: "mods/root.jar".to_string(),
|
||||
project_type: crate::state::ProjectType::Mod,
|
||||
provider: None,
|
||||
project_id: None,
|
||||
current_release_id: None,
|
||||
current_enabled: true,
|
||||
auto_dependency: false,
|
||||
status: crate::state::InstanceUpgradeItemStatus::Unidentified,
|
||||
resolution: InstanceUpgradeResolution {
|
||||
content_id: "root".to_string(),
|
||||
action: InstanceUpgradeAction::Keep,
|
||||
allow_prerelease: false,
|
||||
confirmed_prerelease_dependencies: Vec::new(),
|
||||
},
|
||||
candidate_release_ids: Vec::new(),
|
||||
});
|
||||
let request = plan.items[0].resolution.clone();
|
||||
assert!(!resolution_is_applicable(&plan, &request));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_plan_mutex_serializes_mutations_without_lost_update() {
|
||||
let plan = Arc::new(Mutex::new(empty_plan()));
|
||||
let acquired = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let first_plan = Arc::clone(&plan);
|
||||
let first_acquired = Arc::clone(&acquired);
|
||||
let first_release = Arc::clone(&release);
|
||||
let first = tokio::spawn(async move {
|
||||
let mut stored = first_plan.lock().await;
|
||||
first_acquired.notify_one();
|
||||
first_release.notified().await;
|
||||
stored
|
||||
.custom_constraints
|
||||
.push(InstanceUpgradeFixedConstraint {
|
||||
content_id: "a".to_string(),
|
||||
provider: crate::state::ContentProvider::Modrinth,
|
||||
project_id: "a".to_string(),
|
||||
version_id: "a-one".to_string(),
|
||||
});
|
||||
});
|
||||
acquired.notified().await;
|
||||
let second_plan = Arc::clone(&plan);
|
||||
let second = tokio::spawn(async move {
|
||||
let mut stored = second_plan.lock().await;
|
||||
stored
|
||||
.custom_constraints
|
||||
.push(InstanceUpgradeFixedConstraint {
|
||||
content_id: "b".to_string(),
|
||||
provider: crate::state::ContentProvider::Modrinth,
|
||||
project_id: "b".to_string(),
|
||||
version_id: "b-one".to_string(),
|
||||
});
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
release.notify_one();
|
||||
first.await.unwrap();
|
||||
second.await.unwrap();
|
||||
let stored = plan.lock().await;
|
||||
assert_eq!(stored.custom_constraints.len(), 2);
|
||||
}
|
||||
|
||||
fn empty_plan() -> InstanceUpgradePlan {
|
||||
let environment = crate::state::InstanceUpgradeEnvironment {
|
||||
game_version: "1.21.1".to_string(),
|
||||
mod_loader: crate::state::ModLoader::Fabric,
|
||||
mod_loader_version: None,
|
||||
shader_runtime: crate::state::ShaderRuntime::Iris,
|
||||
};
|
||||
InstanceUpgradePlan {
|
||||
id: "plan".to_string(),
|
||||
instance_id: "instance".to_string(),
|
||||
source_revision: 1,
|
||||
source_files: Vec::new(),
|
||||
source_environment: environment.clone(),
|
||||
target_environment: environment,
|
||||
items: Vec::new(),
|
||||
dependency_changes: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
blocking_issues: Vec::new(),
|
||||
newest_solution: None,
|
||||
minimal_change_solution: None,
|
||||
selected_solution: None,
|
||||
custom_constraints: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user