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

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

No files matched your search

@@ -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"
);
}
}
+21
View File
@@ -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
}
+53
View File
@@ -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"
));
}
}
+224
View File
@@ -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()
})
}
+516
View File
@@ -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(),
}
}
}