Files
modrinth/packages/app-lib/src/state/instances/commands/apply_content_update.rs
T
Calum H.andsychic e58af98f21 feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call

* feat: invite players modal

* feat: use tanstack queries for friends sync across app pages

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

* feat: warnings on deleting/disabling shared-instance version content

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

* [do not merge] feat: rough invite links impl temp (#6666)

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

* refactor: split up shared instances state outside of instance index

* refactor: dedicated shared instances install/update modals + split up page

* refactor: centralized managed content

* refactor: split up install shared to own runner + shared.rs split up

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

* feat: shared instances moderation frontend + hook up report flow

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
2026-07-24 13:06:38 +00:00

692 lines
20 KiB
Rust

use crate::state::instances::{
ContentEntry, ContentSet, ContentSourceKind, InstanceFile,
adapters::sqlite::{content_rows, instance_rows},
};
use crate::state::{
CacheBehaviour, CachedEntry, Dependency, DependencyType, State, Version,
};
use crate::util::fetch::DownloadReason;
use futures::stream::{FuturesUnordered, StreamExt};
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use super::apply_content_install::{
DownloadedProjectVersion, add_downloaded_project_version,
add_project_from_version, download_project_version, remove_project,
rename_project_companion_file, toggle_disable_project,
};
use super::check_content_updates::{ContentUpdate, check_content_updates};
#[derive(Clone, Debug)]
struct BulkUpdatePlan {
project_updates: Vec<PlannedProjectUpdate>,
dependency_additions: Vec<PlannedDependencyInstall>,
}
#[derive(Clone, Debug)]
struct PlannedProjectUpdate {
relative_path: String,
current_version_id: String,
update_version_id: String,
}
#[derive(Clone, Debug)]
struct PlannedDependencyInstall {
version_id: String,
parent_version_id: String,
}
#[derive(Clone, Debug)]
enum PlannedDownload {
ProjectUpdate(PlannedProjectUpdate),
DependencyAddition(PlannedDependencyInstall),
}
enum DownloadedBulkProject {
ProjectUpdate(PlannedProjectUpdate, DownloadedProjectVersion),
DependencyAddition(DownloadedProjectVersion),
}
#[derive(Clone, Debug)]
struct InstalledProject {
relative_path: String,
project_id: Option<String>,
version_id: Option<String>,
source_kind: ContentSourceKind,
enabled: bool,
}
#[derive(Clone, Debug)]
struct ResolvedDependency {
project_id: String,
version_id: String,
parent_version_id: String,
}
pub(crate) async fn update_project(
instance_id: &str,
project_path: &str,
state: &State,
) -> crate::Result<String> {
let updates = check_content_updates(
instance_id,
Some(CacheBehaviour::MustRevalidate),
state,
)
.await?;
let update = updates
.into_iter()
.find(|update| update.relative_path == project_path)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"This project cannot be updated!".to_string(),
)
})?;
apply_content_update(instance_id, project_path, &update, state).await
}
async fn apply_content_update(
instance_id: &str,
project_path: &str,
update: &ContentUpdate,
state: &State,
) -> crate::Result<String> {
let mut new_path = add_project_from_version(
instance_id,
&update.update_version_id,
DownloadReason::Update,
Some(update.current_version_id.clone()),
ContentSourceKind::Local,
state,
)
.await?;
if project_path.ends_with(".disabled") {
new_path =
toggle_disable_project(instance_id, &new_path, Some(false), state)
.await?;
}
if new_path != project_path {
rename_project_companion_file(
instance_id,
project_path,
&new_path,
state,
)
.await?;
remove_project(instance_id, project_path, state).await?;
}
Ok(new_path)
}
pub(crate) async fn update_all_projects(
instance_id: &str,
state: &State,
) -> crate::Result<HashMap<String, String>> {
emit_bulk_update_progress(
instance_id,
crate::event::InstanceBulkUpdateProgressStage::ResolvingVersions,
0,
0,
)
.await?;
let plan = plan_bulk_update(instance_id, state).await?;
let download_total =
plan.project_updates.len() + plan.dependency_additions.len();
let downloads =
download_planned_projects(instance_id, &plan, download_total, state)
.await?;
let mut changed = HashMap::new();
emit_bulk_update_progress(
instance_id,
crate::event::InstanceBulkUpdateProgressStage::Finishing,
download_total,
download_total,
)
.await?;
for download in downloads {
match download {
DownloadedBulkProject::ProjectUpdate(update, downloaded) => {
let mut new_path = add_downloaded_project_version(
instance_id,
downloaded,
ContentSourceKind::Local,
state,
)
.await?;
if update.relative_path.ends_with(".disabled") {
new_path = toggle_disable_project(
instance_id,
&new_path,
Some(false),
state,
)
.await?;
}
if new_path != update.relative_path {
rename_project_companion_file(
instance_id,
&update.relative_path,
&new_path,
state,
)
.await?;
remove_project(instance_id, &update.relative_path, state)
.await?;
}
changed.insert(update.relative_path, new_path);
}
DownloadedBulkProject::DependencyAddition(downloaded) => {
add_downloaded_project_version(
instance_id,
downloaded,
ContentSourceKind::Local,
state,
)
.await?;
}
}
}
Ok(changed)
}
async fn download_planned_projects(
instance_id: &str,
plan: &BulkUpdatePlan,
total: usize,
state: &State,
) -> crate::Result<Vec<DownloadedBulkProject>> {
emit_bulk_update_progress(
instance_id,
crate::event::InstanceBulkUpdateProgressStage::Downloading,
0,
total,
)
.await?;
let mut downloads = plan
.project_updates
.iter()
.cloned()
.map(PlannedDownload::ProjectUpdate)
.chain(
plan.dependency_additions
.iter()
.cloned()
.map(PlannedDownload::DependencyAddition),
)
.map(|download| async move {
match download {
PlannedDownload::ProjectUpdate(update) => {
let downloaded = download_project_version(
instance_id,
&update.update_version_id,
DownloadReason::Update,
Some(update.current_version_id.clone()),
state,
)
.await?;
Ok::<_, crate::Error>(DownloadedBulkProject::ProjectUpdate(
update, downloaded,
))
}
PlannedDownload::DependencyAddition(dependency) => {
let downloaded = download_project_version(
instance_id,
&dependency.version_id,
DownloadReason::Dependency,
Some(dependency.parent_version_id.clone()),
state,
)
.await?;
Ok::<_, crate::Error>(
DownloadedBulkProject::DependencyAddition(downloaded),
)
}
}
})
.collect::<FuturesUnordered<_>>();
let mut completed = 0;
let mut output = Vec::with_capacity(total);
while let Some(download) = downloads.next().await {
let download = download?;
completed += 1;
emit_bulk_update_progress(
instance_id,
crate::event::InstanceBulkUpdateProgressStage::Downloading,
completed,
total,
)
.await?;
output.push(download);
}
Ok(output)
}
async fn emit_bulk_update_progress(
instance_id: &str,
stage: crate::event::InstanceBulkUpdateProgressStage,
current: usize,
total: usize,
) -> crate::Result<()> {
crate::event::emit::emit_instance_bulk_update_progress(
crate::event::InstanceBulkUpdateProgressPayload {
instance_id: instance_id.to_string(),
stage,
current,
total,
},
)
.await
}
async fn plan_bulk_update(
instance_id: &str,
state: &State,
) -> crate::Result<BulkUpdatePlan> {
let shared_instance_member =
is_shared_instance_member(instance_id, state).await?;
let updateable_paths = bulk_updateable_project_paths(
instance_id,
shared_instance_member,
state,
)
.await?;
if updateable_paths.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
});
}
let updates = check_content_updates(
instance_id,
Some(CacheBehaviour::MustRevalidate),
state,
)
.await?
.into_iter()
.filter(|update| updateable_paths.contains(&update.relative_path))
.collect::<Vec<_>>();
if updates.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
});
}
let content_set =
content_rows::get_applied_content_set(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Instance {instance_id} has no applied content set"
))
})?;
let installed =
installed_projects(instance_id, &content_set, state).await?;
let updateable_paths = if shared_instance_member {
let managed_paths = installed
.iter()
.filter(|project| project.source_kind.is_shared_instance_managed())
.map(|project| project.relative_path.clone())
.collect::<HashSet<_>>();
updateable_paths
.into_iter()
.filter(|path| !managed_paths.contains(path))
.collect::<HashSet<_>>()
} else {
updateable_paths
};
let updates = updates
.into_iter()
.filter(|update| updateable_paths.contains(&update.relative_path))
.collect::<Vec<_>>();
if updates.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
});
}
let installed_by_project = installed
.iter()
.filter_map(|project| {
project
.project_id
.as_ref()
.map(|project_id| (project_id.clone(), project.clone()))
})
.collect::<HashMap<_, _>>();
let updates_by_path = updates
.iter()
.map(|update| {
(
update.relative_path.clone(),
(
update.current_version_id.clone(),
update.update_version_id.clone(),
),
)
})
.collect::<HashMap<_, _>>();
let version_ids = installed
.iter()
.filter(|project| updateable_paths.contains(&project.relative_path))
.filter_map(|project| project.version_id.clone())
.chain(
updates
.iter()
.map(|update| update.update_version_id.clone()),
)
.collect::<HashSet<_>>();
let version_id_refs =
version_ids.iter().map(|id| id.as_str()).collect::<Vec<_>>();
let versions = CachedEntry::get_version_many(
&version_id_refs,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
let versions_by_id = versions
.into_iter()
.map(|version| (version.id.clone(), version))
.collect::<HashMap<_, _>>();
let planned_versions = installed
.iter()
.filter(|project| project.enabled)
.filter(|project| updateable_paths.contains(&project.relative_path))
.filter_map(|project| {
let target_version_id = updates_by_path
.get(&project.relative_path)
.map(|(_, update_version_id)| update_version_id)
.or(project.version_id.as_ref())?;
versions_by_id.get(target_version_id).cloned()
})
.collect::<Vec<_>>();
let planned_dependencies =
dependency_closure(planned_versions, &content_set, state).await?;
let dependency_additions = planned_dependencies
.values()
.filter(|dependency| {
!installed_by_project.contains_key(&dependency.project_id)
})
.map(|dependency| PlannedDependencyInstall {
version_id: dependency.version_id.clone(),
parent_version_id: dependency.parent_version_id.clone(),
})
.collect::<Vec<_>>();
let project_updates = updates
.into_iter()
.map(|update| PlannedProjectUpdate {
relative_path: update.relative_path,
current_version_id: update.current_version_id,
update_version_id: update.update_version_id,
})
.collect::<Vec<_>>();
Ok(BulkUpdatePlan {
project_updates,
dependency_additions,
})
}
async fn bulk_updateable_project_paths(
instance_id: &str,
shared_instance_member: bool,
state: &State,
) -> crate::Result<HashSet<String>> {
let items = super::list_content::list_content(
instance_id,
None,
Some(CacheBehaviour::MustRevalidate),
state,
)
.await?;
Ok(items
.into_iter()
.filter(|item| {
!shared_instance_member
|| !item
.source_kind
.is_some_and(ContentSourceKind::is_shared_instance_managed)
})
.map(|item| item.file_path)
.collect())
}
async fn installed_projects(
instance_id: &str,
content_set: &ContentSet,
state: &State,
) -> crate::Result<Vec<InstalledProject>> {
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let entries =
content_rows::get_content_entries(&content_set.id, &state.pool).await?;
let entries_by_file_id = entries
.iter()
.filter_map(|entry| {
entry.file_id.as_deref().map(|file_id| (file_id, entry))
})
.collect::<HashMap<_, _>>();
let files =
content_rows::get_instance_files(&instance.id, &state.pool).await?;
Ok(files
.into_iter()
.filter_map(|file| {
let entry = entries_by_file_id.get(file.id.as_str())?;
installed_project_from_row(&file, entry)
})
.collect())
}
fn installed_project_from_row(
file: &InstanceFile,
entry: &ContentEntry,
) -> Option<InstalledProject> {
if entry.project_id.is_none() && entry.version_id.is_none() {
return None;
}
Some(InstalledProject {
relative_path: file.relative_path.clone(),
project_id: entry.project_id.clone(),
version_id: entry.version_id.clone(),
source_kind: entry.source_kind,
enabled: entry.enabled && file.enabled,
})
}
async fn is_shared_instance_member(
instance_id: &str,
state: &State,
) -> crate::Result<bool> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, &state.pool)
.await?
else {
return Ok(false);
};
Ok(metadata
.shared_instance
.is_some_and(|attachment| attachment.role.is_member()))
}
async fn dependency_closure(
root_versions: Vec<Version>,
content_set: &ContentSet,
state: &State,
) -> crate::Result<HashMap<String, ResolvedDependency>> {
let mut output = HashMap::new();
let mut stack = root_versions;
let mut visited_versions = HashSet::new();
let mut version_cache = HashMap::new();
let mut project_versions_cache = HashMap::new();
while let Some(version) = stack.pop() {
if !visited_versions.insert(version.id.clone()) {
continue;
}
for dependency in &version.dependencies {
if !is_required_dependency(dependency, content_set) {
continue;
}
let Some(dependency_version) = resolve_dependency_version(
dependency,
content_set,
state,
&mut version_cache,
&mut project_versions_cache,
)
.await?
else {
continue;
};
let project_id = dependency
.project_id
.clone()
.unwrap_or_else(|| dependency_version.project_id.clone());
output.entry(project_id.clone()).or_insert_with(|| {
ResolvedDependency {
project_id,
version_id: dependency_version.id.clone(),
parent_version_id: version.id.clone(),
}
});
stack.push(dependency_version);
}
}
Ok(output)
}
fn is_required_dependency(
dependency: &Dependency,
content_set: &ContentSet,
) -> bool {
matches!(dependency.dependency_type, DependencyType::Required)
&& !(dependency.project_id.as_deref() == Some("P7dR8mSH")
&& content_set.loader.as_str() == "quilt")
}
async fn resolve_dependency_version(
dependency: &Dependency,
content_set: &ContentSet,
state: &State,
version_cache: &mut HashMap<String, Option<Version>>,
project_versions_cache: &mut HashMap<String, Option<Vec<Version>>>,
) -> crate::Result<Option<Version>> {
if let Some(version_id) = &dependency.version_id {
return cached_version(version_id, version_cache, state).await;
}
let Some(project_id) = &dependency.project_id else {
return Ok(None);
};
let Some(mut versions) =
cached_project_versions(project_id, project_versions_cache, state)
.await?
else {
return Ok(None);
};
versions.sort_by_key(|version| Reverse(version.date_published));
Ok(find_preferred_dependency_version(&versions, content_set))
}
async fn cached_version(
version_id: &str,
version_cache: &mut HashMap<String, Option<Version>>,
state: &State,
) -> crate::Result<Option<Version>> {
if !version_cache.contains_key(version_id) {
let version = CachedEntry::get_version(
version_id,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
version_cache.insert(version_id.to_string(), version);
}
Ok(version_cache.get(version_id).cloned().flatten())
}
async fn cached_project_versions(
project_id: &str,
project_versions_cache: &mut HashMap<String, Option<Vec<Version>>>,
state: &State,
) -> crate::Result<Option<Vec<Version>>> {
if !project_versions_cache.contains_key(project_id) {
let versions = CachedEntry::get_project_versions(
project_id,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
project_versions_cache.insert(project_id.to_string(), versions);
}
Ok(project_versions_cache.get(project_id).cloned().flatten())
}
fn find_preferred_dependency_version(
versions: &[Version],
content_set: &ContentSet,
) -> Option<Version> {
versions
.iter()
.find(|version| {
version.game_versions.contains(&content_set.game_version)
&& version
.loaders
.iter()
.any(|loader| loader == content_set.loader.as_str())
})
.or_else(|| {
versions.iter().find(|version| {
is_dependency_version_compatible(version, content_set)
})
})
.cloned()
}
fn is_dependency_version_compatible(
version: &Version,
content_set: &ContentSet,
) -> bool {
version.game_versions.contains(&content_set.game_version)
&& (version
.loaders
.iter()
.any(|loader| loader == content_set.loader.as_str())
|| version.loaders.iter().any(|loader| loader == "datapack"))
}