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>
This commit is contained in:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+21 -1
View File
@@ -30,6 +30,26 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
Some(("server", id)) => {
CommandPayload::InstallServer { id: id.to_string() }
}
// /share/{invite_id}
Some(("share", raw)) => {
let (raw, _) = raw.split_once('?').unwrap_or((raw, ""));
match decode(raw) {
Ok(decoded) => CommandPayload::InstallSharedInstanceInvite {
invite_id: decoded.to_string(),
},
Err(e) => {
emit_warning(&format!(
"Invalid UTF-8 in shared instance invite path: {e}"
))
.await?;
return Err(crate::ErrorKind::InputError(format!(
"Invalid UTF-8 in shared instance invite path: {e}"
))
.into());
}
}
}
// /launch/instance/{id} - Launches an instance
Some(("launch", rest)) if rest.starts_with("instance/") => {
let raw = rest.trim_start_matches("instance/");
@@ -93,7 +113,7 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
pub async fn parse_command(
command_string: &str,
) -> crate::Result<CommandPayload> {
tracing::debug!("Parsing command: {}", &command_string);
tracing::debug!("Parsing external command");
// modrinth://some-command
// This occurs when following a web redirect link
+23
View File
@@ -1,6 +1,7 @@
//! Theseus instance management interface
mod content;
mod content_set_diff;
mod export_mrpack;
mod get;
mod install;
@@ -8,6 +9,7 @@ mod lifecycle;
mod paths;
mod projects;
mod run;
mod shared;
pub use self::content::{
get_content_items, get_dependencies_as_content_items,
@@ -33,3 +35,24 @@ pub use self::projects::{
pub use self::run::{
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
};
pub(crate) use self::shared::{
CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS,
CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES,
read_bounded_config_bundle_entry,
};
pub use self::shared::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
SharedInstanceJoinType, SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
accept_pending_shared_instance_invite,
accept_shared_instance_invite_for_install,
can_active_user_use_shared_instances, create_shared_instance_invite_link,
decline_pending_shared_instance_invite,
get_shared_instance_install_preview, get_shared_instance_publish_preview,
get_shared_instance_update_preview, get_shared_instance_users,
install_shared_instance, invite_shared_instance_users,
publish_shared_instance, remove_shared_instance_users,
unlink_shared_instance, unpublish_shared_instance, update_shared_instance,
};
@@ -0,0 +1,152 @@
use crate::state::Version;
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ContentSetDiffKind {
Added,
Removed,
Updated,
}
#[derive(Clone, Debug)]
pub(crate) enum ContentSetDiffEntry {
Project {
kind: ContentSetDiffKind,
project_id: String,
current_version_name: Option<String>,
new_version_name: Option<String>,
disabled: bool,
},
ExternalFile {
kind: ContentSetDiffKind,
file_name: String,
disabled: bool,
},
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ContentSetDiffOptions {
pub removed_disabled_project_ids: HashSet<String>,
pub removed_disabled_external_files: HashSet<String>,
pub common_external_files_are_updated: bool,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ContentSetSnapshot {
pub versions: Vec<ContentSetSnapshotVersion>,
pub external_files: HashSet<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct ContentSetSnapshotVersion {
pub project_id: String,
pub version_id: String,
pub version_name: String,
}
impl From<Version> for ContentSetSnapshotVersion {
fn from(version: Version) -> Self {
Self {
project_id: version.project_id,
version_id: version.id,
version_name: version.version_number,
}
}
}
pub(crate) fn diff_content_sets(
current: &ContentSetSnapshot,
latest: &ContentSetSnapshot,
options: &ContentSetDiffOptions,
) -> Vec<ContentSetDiffEntry> {
let current_versions = versions_by_project(current);
let latest_versions = versions_by_project(latest);
let project_ids = current_versions
.keys()
.chain(latest_versions.keys())
.copied()
.collect::<HashSet<_>>();
let mut diffs = Vec::new();
for project_id in project_ids {
let current = current_versions.get(project_id);
let latest = latest_versions.get(project_id);
match (current, latest) {
(None, Some(latest)) => {
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Added,
project_id: project_id.to_string(),
current_version_name: None,
new_version_name: Some(latest.version_name.clone()),
disabled: false,
});
}
(Some(current), None) => {
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Removed,
project_id: project_id.to_string(),
current_version_name: Some(current.version_name.clone()),
new_version_name: None,
disabled: options
.removed_disabled_project_ids
.contains(project_id),
});
}
(Some(current), Some(latest))
if current.version_id != latest.version_id =>
{
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Updated,
project_id: project_id.to_string(),
current_version_name: Some(current.version_name.clone()),
new_version_name: Some(latest.version_name.clone()),
disabled: false,
});
}
_ => {}
}
}
for file_name in latest.external_files.difference(&current.external_files) {
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Added,
file_name: file_name.clone(),
disabled: false,
});
}
if options.common_external_files_are_updated {
for file_name in
latest.external_files.intersection(&current.external_files)
{
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Updated,
file_name: file_name.clone(),
disabled: false,
});
}
}
for file_name in current.external_files.difference(&latest.external_files) {
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Removed,
file_name: file_name.clone(),
disabled: options
.removed_disabled_external_files
.contains(file_name),
});
}
diffs
}
fn versions_by_project(
snapshot: &ContentSetSnapshot,
) -> HashMap<&str, &ContentSetSnapshotVersion> {
snapshot
.versions
.iter()
.map(|version| (version.project_id.as_str(), version))
.collect()
}
+16 -1
View File
@@ -101,12 +101,27 @@ pub async fn edit_icon(
crate::state::edit_instance(
instance_id,
EditInstance {
icon_path: Some(icon_path),
icon_path: Some(icon_path.clone()),
..EditInstance::default()
},
&state.pool,
)
.await?;
if let Err(error) = super::shared::sync_shared_instance_icon(
instance_id,
icon_path.as_deref(),
&state,
)
.await
{
tracing::warn!(
instance_id,
error = %error,
"Failed to sync shared instance icon"
);
}
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
Ok(())
+102 -9
View File
@@ -1,7 +1,9 @@
use crate::event::emit::{emit_instance, emit_loading, init_loading};
use crate::event::{InstancePayloadType, LoadingBarType};
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::{CacheBehaviour, CachedEntry, ProjectType, State};
use crate::state::{
CacheBehaviour, CachedEntry, ContentSourceKind, ProjectType, State,
};
use crate::util::fetch;
use modrinth_content_management::{
ContentType, ResolutionPreferences, ResolveContentPlan,
@@ -23,6 +25,7 @@ pub async fn update_all_projects(
instance_id: &str,
) -> crate::Result<HashMap<String, String>> {
let state = State::get().await?;
ensure_instance_content_unlocked(instance_id, &state).await?;
let instance = get_instance_display_info(instance_id, &state).await?;
let loading_bar = init_loading(
LoadingBarType::InstanceUpdate {
@@ -51,13 +54,18 @@ pub async fn update_project(
skip_send_event: Option<bool>,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(
instance_id,
project_path,
&state,
)
.await?;
let path = crate::state::instances::commands::update_project(
instance_id,
project_path,
&state,
)
.await?;
if !skip_send_event.unwrap_or(false) {
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
@@ -73,6 +81,7 @@ pub async fn add_project_from_version(
dependent_on_version_id: Option<String>,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_instance_content_unlocked(instance_id, &state).await?;
let project_path =
crate::state::instances::commands::add_project_from_version(
instance_id,
@@ -97,6 +106,7 @@ pub async fn install_project_with_dependencies(
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
ensure_metadata_content_unlocked(&metadata)?;
let plan = crate::state::instances::commands::resolve_install_plan(
instance_id,
crate::state::instances::commands::InstanceInstallProjectRequest {
@@ -181,6 +191,12 @@ pub async fn switch_project_version_with_dependencies(
version_id: &str,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(
instance_id,
project_path,
&state,
)
.await?;
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
@@ -204,13 +220,18 @@ pub async fn add_project_from_path(
project_type: Option<ProjectType>,
) -> crate::Result<String> {
let state = State::get().await?;
crate::state::instances::commands::add_project_from_path(
instance_id,
path,
project_type,
&state,
)
.await
ensure_instance_content_unlocked(instance_id, &state).await?;
let project_path =
crate::state::instances::commands::add_project_from_path(
instance_id,
path,
project_type,
&state,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(project_path)
}
#[tracing::instrument]
@@ -235,6 +256,8 @@ pub async fn toggle_disable_project(
desired_enabled: Option<bool>,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(instance_id, project, &state)
.await?;
let res = crate::state::instances::commands::toggle_disable_project(
instance_id,
project,
@@ -253,6 +276,8 @@ pub async fn remove_project(
project: &str,
) -> crate::Result<()> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(instance_id, project, &state)
.await?;
crate::state::instances::commands::remove_project(
instance_id,
project,
@@ -264,6 +289,45 @@ pub async fn remove_project(
Ok(())
}
async fn ensure_shared_instance_can_modify_project(
instance_id: &str,
project_path: &str,
state: &State,
) -> crate::Result<()> {
let metadata = crate::state::instances::commands::get_instance_metadata(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
ensure_metadata_content_unlocked(&metadata)?;
if !metadata
.shared_instance
.is_some_and(|attachment| attachment.role.is_member())
{
return Ok(());
}
let source_kind =
crate::state::instances::commands::content_source_kind_for_project_path(
instance_id,
project_path,
state,
)
.await?;
if source_kind.is_some_and(ContentSourceKind::is_shared_instance_managed) {
return Err(crate::ErrorKind::InputError(
"Shared instance managed content cannot be changed directly."
.to_string(),
)
.into());
}
Ok(())
}
#[tracing::instrument]
pub async fn update_managed_modrinth_version(
instance_id: &str,
@@ -278,6 +342,7 @@ pub async fn update_managed_modrinth_version(
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
ensure_metadata_content_unlocked(&metadata)?;
let post_install_edit = match &metadata.link {
crate::state::InstanceLink::ServerProjectModpack {
@@ -335,6 +400,7 @@ pub async fn repair_managed_modrinth(
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
ensure_metadata_content_unlocked(&metadata)?;
let post_install_edit = match &metadata.link {
crate::state::InstanceLink::ServerProjectModpack { .. } => {
@@ -381,6 +447,33 @@ fn unmanaged_pack_error(instance_id: &str) -> crate::ErrorKind {
))
}
async fn ensure_instance_content_unlocked(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
if instance_rows::is_instance_quarantined(instance_id, &state.pool).await? {
return Err(quarantined_content_error().into());
}
Ok(())
}
fn ensure_metadata_content_unlocked(
metadata: &crate::state::InstanceMetadata,
) -> crate::Result<()> {
if metadata.quarantined {
return Err(quarantined_content_error().into());
}
Ok(())
}
fn quarantined_content_error() -> crate::ErrorKind {
crate::ErrorKind::InputError(
"Content in quarantined instances cannot be changed.".to_string(),
)
}
async fn get_instance_display_info(
instance_id: &str,
state: &State,
+28
View File
@@ -24,6 +24,23 @@ pub async fn run(
quick_play_type: QuickPlayType,
) -> crate::Result<ProcessMetadata> {
let state = State::get().await?;
if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined(
instance_id,
&state.pool,
)
.await?
{
return Err(crate::ErrorKind::InputError(
"This instance has been quarantined".to_string(),
)
.into());
}
super::shared::check_shared_instance_availability_before_launch(
instance_id,
&state,
)
.await?;
let default_account = Credentials::get_default_credential(&state.pool)
.await?
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?;
@@ -50,6 +67,17 @@ async fn run_credentials(
"Tried to run a nonexistent instance {instance_id}!"
))
})?;
if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined(
instance_id,
&state.pool,
)
.await?
{
return Err(crate::ErrorKind::InputError(
"This instance has been quarantined".to_string(),
)
.into());
}
let pre_launch_hooks = context
.launch_overrides
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,432 @@
use super::client::*;
use super::publish::*;
use super::types::*;
use super::*;
pub(super) async fn shared_instance_update_diffs(
metadata: &crate::state::InstanceMetadata,
version: &InstanceVersionResponse,
state: &State,
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
let remote_modpack_id =
version.modpack_id.as_deref().filter(|id| !id.is_empty());
let current_modpack_id = shared_modpack_id(&metadata.link);
let modpack_unlinked =
current_modpack_id.is_some() && remote_modpack_id.is_none();
let (current_version_ids, current_external_files) =
current_shared_content(metadata, modpack_unlinked, state).await?;
let (latest_version_ids, latest_external_files) =
remote_shared_content(version);
let removed_disabled_project_ids = HashSet::new();
let removed_disabled_external_files = HashSet::new();
let mut diffs = shared_content_diffs(
&current_version_ids,
&current_external_files,
&latest_version_ids,
&latest_external_files,
&removed_disabled_project_ids,
&removed_disabled_external_files,
true,
state,
)
.await?;
let mut configuration_diffs = shared_instance_configuration_diffs(
current_modpack_id.as_deref(),
remote_modpack_id,
&metadata.applied_content_set.game_version,
&version.game_version,
metadata.applied_content_set.loader,
version.loader,
metadata.applied_content_set.loader_version.as_deref(),
Some(version.loader_version.as_str()),
state,
)
.await?;
configuration_diffs.append(&mut diffs);
Ok(configuration_diffs)
}
pub(super) async fn shared_instance_publish_diffs(
metadata: &crate::state::InstanceMetadata,
version: &InstanceVersionResponse,
snapshot: &CurrentPublishSnapshot,
state: &State,
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
let remote_modpack_id =
version.modpack_id.as_deref().filter(|id| !id.is_empty());
let current_modpack_id = shared_modpack_id(&metadata.link);
let modpack_unlinked =
remote_modpack_id.is_some() && current_modpack_id.is_none();
let disabled_versions = async {
if snapshot.disabled_version_ids.is_empty() {
Ok(HashMap::new())
} else {
shared_versions_by_project(&snapshot.disabled_version_ids, state)
.await
}
};
let ((latest_version_ids, latest_external_files), disabled_versions) = tokio::try_join!(
remote_publish_content(version, modpack_unlinked, state),
disabled_versions,
)?;
let current_external_files = snapshot
.external_files
.iter()
.map(|file| file.file_name.clone())
.collect::<HashSet<_>>();
let current_version_ids = snapshot
.version_ids
.iter()
.filter(|id| current_modpack_id.as_deref() != Some(id.as_str()))
.cloned()
.collect::<Vec<_>>();
let mut removed_disabled_project_ids =
snapshot.disabled_project_ids.clone();
removed_disabled_project_ids.extend(disabled_versions.into_keys());
let mut diffs = shared_content_diffs(
&latest_version_ids,
&latest_external_files,
&current_version_ids,
&current_external_files,
&removed_disabled_project_ids,
&snapshot.disabled_external_files,
false,
state,
)
.await?;
let mut configuration_diffs = shared_instance_configuration_diffs(
remote_modpack_id,
current_modpack_id.as_deref(),
&version.game_version,
&metadata.applied_content_set.game_version,
version.loader,
metadata.applied_content_set.loader,
Some(version.loader_version.as_str()),
metadata.applied_content_set.loader_version.as_deref(),
state,
)
.await?;
configuration_diffs.append(&mut diffs);
Ok(configuration_diffs)
}
pub(super) async fn shared_instance_configuration_diffs(
current_modpack_id: Option<&str>,
new_modpack_id: Option<&str>,
current_game_version: &str,
new_game_version: &str,
current_loader: ModLoader,
new_loader: ModLoader,
current_loader_version: Option<&str>,
new_loader_version: Option<&str>,
state: &State,
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
let mut diffs = Vec::new();
if current_modpack_id != new_modpack_id {
match (current_modpack_id, new_modpack_id) {
(None, Some(_)) => diffs.push(configuration_diff(
SharedInstanceUpdateDiffType::ModpackLinked,
None,
shared_modpack_version_label(new_modpack_id, state).await,
)),
(Some(_), None) => diffs.push(configuration_diff(
SharedInstanceUpdateDiffType::ModpackUnlinked,
shared_modpack_version_label(current_modpack_id, state).await,
None,
)),
(Some(current_modpack_id), Some(new_modpack_id)) => {
let current =
shared_modpack_version_details(current_modpack_id, state)
.await;
let new =
shared_modpack_version_details(new_modpack_id, state).await;
let project_name = new
.as_ref()
.and_then(|details| details.project_name.clone())
.or_else(|| {
current
.as_ref()
.and_then(|details| details.project_name.clone())
});
diffs.push(SharedInstanceUpdateDiff {
type_: SharedInstanceUpdateDiffType::ModpackUpdated,
project_id: None,
project_name,
file_name: None,
current_version_name: current
.map(|details| details.version_name),
new_version_name: new.map(|details| details.version_name),
config_file_count: None,
disabled: false,
});
}
(None, None) => unreachable!(),
}
}
if current_game_version != new_game_version {
diffs.push(configuration_diff(
SharedInstanceUpdateDiffType::GameVersionUpdated,
Some(current_game_version.to_string()),
Some(new_game_version.to_string()),
));
}
let current_loader_version =
normalized_loader_version(current_loader_version);
let new_loader_version = normalized_loader_version(new_loader_version);
if current_loader != new_loader
|| current_loader_version != new_loader_version
{
diffs.push(configuration_diff(
SharedInstanceUpdateDiffType::LoaderUpdated,
Some(shared_loader_label(current_loader, current_loader_version)),
Some(shared_loader_label(new_loader, new_loader_version)),
));
}
Ok(diffs)
}
pub(super) fn configuration_diff(
type_: SharedInstanceUpdateDiffType,
current_version_name: Option<String>,
new_version_name: Option<String>,
) -> SharedInstanceUpdateDiff {
SharedInstanceUpdateDiff {
type_,
project_id: None,
project_name: None,
file_name: None,
current_version_name,
new_version_name,
config_file_count: None,
disabled: false,
}
}
pub(super) async fn shared_modpack_version_label(
version_id: Option<&str>,
state: &State,
) -> Option<String> {
let version_id = version_id?;
let details = shared_modpack_version_details(version_id, state).await?;
Some(match details.project_name {
Some(project_name) => {
format!("{project_name} {}", details.version_name)
}
None => details.version_name,
})
}
struct SharedModpackVersionDetails {
project_name: Option<String>,
version_name: String,
}
async fn shared_modpack_version_details(
version_id: &str,
state: &State,
) -> Option<SharedModpackVersionDetails> {
let Some(version) = CachedEntry::get_version(
version_id,
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
.await
.ok()
.flatten() else {
return Some(SharedModpackVersionDetails {
project_name: None,
version_name: version_id.to_string(),
});
};
let project = CachedEntry::get_project(
&version.project_id,
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
.await
.ok()
.flatten();
Some(SharedModpackVersionDetails {
project_name: Some(
project.map(|project| project.title).unwrap_or(version.name),
),
version_name: version.version_number,
})
}
pub(super) fn normalized_loader_version(
loader_version: Option<&str>,
) -> Option<&str> {
loader_version.filter(|version| !version.is_empty())
}
pub(super) fn shared_loader_label(
loader: ModLoader,
loader_version: Option<&str>,
) -> String {
let loader_name = match loader {
ModLoader::Vanilla => "Vanilla",
ModLoader::Forge => "Forge",
ModLoader::Fabric => "Fabric",
ModLoader::Quilt => "Quilt",
ModLoader::NeoForge => "NeoForge",
};
match loader_version {
Some(version) => format!("{loader_name} {version}"),
None => loader_name.to_string(),
}
}
pub(super) async fn shared_content_diffs(
current_version_ids: &[String],
current_external_files: &HashSet<String>,
latest_version_ids: &[String],
latest_external_files: &HashSet<String>,
removed_disabled_project_ids: &HashSet<String>,
removed_disabled_external_files: &HashSet<String>,
common_external_files_are_updated: bool,
state: &State,
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
let current = shared_content_snapshot(
current_version_ids,
current_external_files,
state,
)
.await?;
let latest = shared_content_snapshot(
latest_version_ids,
latest_external_files,
state,
)
.await?;
let options = ContentSetDiffOptions {
removed_disabled_project_ids: removed_disabled_project_ids.clone(),
removed_disabled_external_files: removed_disabled_external_files
.clone(),
common_external_files_are_updated,
};
let content_diffs = diff_content_sets(&current, &latest, &options);
let project_ids = content_diffs
.iter()
.filter_map(|diff| match diff {
ContentSetDiffEntry::Project { project_id, .. } => {
Some(project_id.clone())
}
ContentSetDiffEntry::ExternalFile { .. } => None,
})
.collect::<HashSet<_>>();
let project_names = shared_project_names(&project_ids, state).await?;
let mut diffs = Vec::new();
for diff in content_diffs {
match diff {
ContentSetDiffEntry::Project {
kind,
project_id,
current_version_name,
new_version_name,
disabled,
} => {
let project_name = Some(
project_names
.get(&project_id)
.cloned()
.unwrap_or_else(|| project_id.clone()),
);
diffs.push(SharedInstanceUpdateDiff {
type_: shared_update_diff_type(kind),
project_id: Some(project_id),
project_name,
file_name: None,
current_version_name,
new_version_name,
config_file_count: None,
disabled,
});
}
ContentSetDiffEntry::ExternalFile {
kind,
file_name,
disabled,
} => {
diffs.push(SharedInstanceUpdateDiff {
type_: shared_update_diff_type(kind),
project_id: None,
project_name: None,
file_name: Some(file_name),
current_version_name: None,
new_version_name: None,
config_file_count: None,
disabled,
});
}
}
}
diffs.sort_by(|a, b| {
a.project_name
.as_deref()
.or(a.file_name.as_deref())
.cmp(&b.project_name.as_deref().or(b.file_name.as_deref()))
});
Ok(diffs)
}
pub(super) fn shared_update_diff_type(
kind: ContentSetDiffKind,
) -> SharedInstanceUpdateDiffType {
match kind {
ContentSetDiffKind::Added => SharedInstanceUpdateDiffType::Added,
ContentSetDiffKind::Removed => SharedInstanceUpdateDiffType::Removed,
ContentSetDiffKind::Updated => SharedInstanceUpdateDiffType::Updated,
}
}
pub(super) async fn shared_content_snapshot(
version_ids: &[String],
external_files: &HashSet<String>,
state: &State,
) -> crate::Result<ContentSetSnapshot> {
let versions = shared_versions_by_project(version_ids, state)
.await?
.into_values()
.map(ContentSetSnapshotVersion::from)
.collect();
Ok(ContentSetSnapshot {
versions,
external_files: external_files.clone(),
})
}
pub(super) fn remote_shared_content(
version: &InstanceVersionResponse,
) -> (Vec<String>, HashSet<String>) {
let mut version_ids = version.modrinth_ids.clone();
if let Some(modpack_id) = version.modpack_id.as_deref() {
version_ids.retain(|id| id != modpack_id);
}
dedupe_strings(&mut version_ids);
(
version_ids,
version
.external_files
.iter()
.filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
.map(|file| file.file_name.clone())
.collect(),
)
}
@@ -0,0 +1,574 @@
use super::client::*;
use super::diff::*;
use super::publish::*;
use super::types::*;
use super::*;
#[tracing::instrument]
pub async fn install_shared_instance(
shared_instance_id: &str,
name: String,
manager_id: Option<String>,
server_manager_name: Option<String>,
server_manager_icon_url: Option<String>,
instance_icon_url: Option<String>,
) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let version = get_latest_remote_version(shared_instance_id, &state).await?;
let data = shared_instance_install_data(
shared_instance_id,
manager_id,
server_manager_name,
server_manager_icon_url,
instance_icon_url,
name,
version,
&state,
)
.await?;
crate::install::create_shared_instance(data).await
}
pub(super) fn shared_instance_invite_install_name(
shared_instance_id: &str,
name: String,
) -> String {
let name = name.trim();
if name.is_empty() || name == "Shared instance" {
return format!("Shared instance {shared_instance_id}");
}
name.to_string()
}
#[tracing::instrument]
pub async fn get_shared_instance_install_preview(
shared_instance_id: &str,
name: String,
) -> crate::Result<SharedInstanceInstallPreview> {
let state = State::get().await?;
let version = get_latest_remote_version(shared_instance_id, &state).await?;
shared_instance_install_preview_from_version(
shared_instance_id.to_string(),
name,
version,
&state,
)
.await
}
#[tracing::instrument(skip(invite_id))]
pub async fn accept_shared_instance_invite_for_install(
invite_id: &str,
) -> crate::Result<SharedInstanceInviteInstallPreview> {
let state = State::get().await?;
let invite = get_shared_instance_invite_info(invite_id, &state).await?;
let shared_instance_id = invite.instance_id;
let instance_icon_url = invite.instance_icon;
let (manager_id, server_manager_name, server_manager_icon_url) =
shared_instance_invite_manager(invite.managers);
let name = shared_instance_invite_install_name(
&shared_instance_id,
invite.instance_name,
);
accept_shared_instance_invite(&shared_instance_id, invite_id, &state)
.await?;
let version =
get_latest_remote_version(&shared_instance_id, &state).await?;
let mut preview = shared_instance_install_preview_from_version(
shared_instance_id.clone(),
name,
version,
&state,
)
.await?;
if instance_icon_url.is_some() {
preview.icon_url.clone_from(&instance_icon_url);
}
Ok(SharedInstanceInviteInstallPreview {
shared_instance_id,
manager_id,
server_manager_name,
server_manager_icon_url,
instance_icon_url,
preview,
})
}
pub(super) fn shared_instance_invite_manager(
managers: Vec<InstanceInviteManagerResponse>,
) -> (Option<String>, Option<String>, Option<String>) {
match managers.into_iter().next() {
Some(InstanceInviteManagerResponse::User { id }) => {
(Some(id), None, None)
}
Some(InstanceInviteManagerResponse::Server { name, icon }) => {
(None, Some(name), icon)
}
None => (None, None, None),
}
}
pub(super) async fn shared_instance_install_preview_from_version(
shared_instance_id: String,
name: String,
version: InstanceVersionResponse,
state: &State,
) -> crate::Result<SharedInstanceInstallPreview> {
let name = match name.trim() {
"" => "Shared instance".to_string(),
name => name.to_string(),
};
let mut content_version_ids = version.modrinth_ids.clone();
let mut seen_content_version_ids = HashSet::new();
content_version_ids
.retain(|id| seen_content_version_ids.insert(id.clone()));
let modpack = shared_instance_install_modpack(&version, state).await?;
let modpack_dependency_count = modpack
.as_ref()
.map(|modpack| modpack.dependency_count)
.unwrap_or_default();
let modpack_version_id =
modpack.as_ref().map(|modpack| modpack.version_id.clone());
if let Some(modpack_version_id) = modpack_version_id.as_deref() {
content_version_ids.retain(|id| id != modpack_version_id);
}
let icon_url = modpack
.as_ref()
.and_then(|modpack| modpack.icon_url.clone());
let external_files = version
.external_files
.iter()
.filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
.map(|file| SharedInstanceExternalFilePreview {
file_name: file.file_name.clone(),
file_type: file.file_type.clone(),
})
.collect::<Vec<_>>();
let external_file_count = external_files.len();
Ok(SharedInstanceInstallPreview {
shared_instance_id,
version: version.version,
name,
icon_url,
game_version: version.game_version,
loader: version.loader,
mod_count: modpack_dependency_count
+ content_version_ids.len()
+ external_file_count,
external_file_count,
modpack_version_id,
content_version_ids,
external_files,
})
}
#[tracing::instrument]
pub async fn get_shared_instance_update_preview(
instance_id: &str,
) -> crate::Result<Option<SharedInstanceUpdatePreview>> {
let state = State::get().await?;
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let Some(attachment) = metadata.shared_instance.clone() else {
return Ok(None);
};
if !attachment.role.is_member() {
if let SharedInstanceRemoteResponse::Unavailable(reason) =
get_remote_instance_access(&attachment.id, &state).await?
{
handle_unavailable_shared_instance_if_current_user(
instance_id,
&attachment,
reason,
&state,
)
.await?;
return Err(shared_instance_unavailable_error(reason));
}
return Ok(None);
}
let version =
get_latest_remote_member_version(instance_id, &attachment, &state)
.await?;
if !version.ready {
return Ok(Some(SharedInstanceUpdatePreview {
shared_instance_id: attachment.id,
current_version: attachment.applied_version,
latest_version: version.version,
update_available: false,
diffs: Vec::new(),
}));
}
let update_available = attachment
.applied_version
.is_none_or(|current| current < version.version);
let diffs = if update_available {
shared_instance_update_diffs(&metadata, &version, &state).await?
} else {
Vec::new()
};
Ok(Some(SharedInstanceUpdatePreview {
shared_instance_id: attachment.id,
current_version: attachment.applied_version,
latest_version: version.version,
update_available,
diffs,
}))
}
pub(crate) async fn check_shared_instance_availability_before_launch(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let Some(attachment) = metadata.shared_instance else {
return Ok(());
};
let availability =
match get_remote_instance_access(&attachment.id, state).await {
Ok(availability) => availability,
Err(error)
if matches!(
error.raw.as_ref(),
crate::ErrorKind::NoCredentialsError
| crate::ErrorKind::FetchError(_)
) =>
{
tracing::warn!(
instance_id,
shared_instance_id = %attachment.id,
error = %error,
"Could not check shared instance availability before launch"
);
return Ok(());
}
Err(error) => return Err(error),
};
if let SharedInstanceRemoteResponse::Unavailable(reason) = availability {
handle_unavailable_shared_instance_if_current_user(
instance_id,
&attachment,
reason,
state,
)
.await?;
return Err(shared_instance_unavailable_error(reason));
}
Ok(())
}
#[tracing::instrument]
pub async fn update_shared_instance(
instance_id: &str,
) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let attachment = metadata.shared_instance.clone().ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance is not attached to a shared instance".to_string(),
)
})?;
if !attachment.role.is_member() {
return Err(crate::ErrorKind::InputError(
"Only shared instance members can update from shared instances"
.to_string(),
)
.into());
}
let version =
get_latest_remote_member_version(instance_id, &attachment, &state)
.await?;
let data = shared_instance_install_data(
&attachment.id,
attachment.manager_id.clone(),
attachment.server_manager_name.clone(),
attachment.server_manager_icon_url.clone(),
None,
metadata.instance.name,
version,
&state,
)
.await?;
crate::install::update_shared_instance(instance_id.to_string(), data).await
}
pub(super) async fn ensure_ready_remote_version_for_invite(
instance_id: &str,
attachment: &SharedInstanceAttachment,
state: &State,
) -> crate::Result<()> {
match get_latest_remote_version_optional_unavailable(&attachment.id, state)
.await?
{
SharedInstanceRemoteResponse::Available(_) => Ok(()),
SharedInstanceRemoteResponse::Unavailable(
SharedInstanceUnavailableReason::Deleted,
) => {
tracing::info!(
instance_id,
shared_instance_id = %attachment.id,
"Shared instance has no ready version before invite; publishing current content"
);
publish_shared_instance_inner(instance_id, &[], state).await
}
SharedInstanceRemoteResponse::Unavailable(reason) => {
handle_unavailable_shared_instance_if_current_user(
instance_id,
attachment,
reason,
state,
)
.await?;
Err(shared_instance_unavailable_error(reason))
}
}
}
pub(super) async fn get_latest_remote_member_version(
instance_id: &str,
attachment: &SharedInstanceAttachment,
state: &State,
) -> crate::Result<InstanceVersionResponse> {
if let SharedInstanceRemoteResponse::Unavailable(reason) =
get_remote_instance_access(&attachment.id, state).await?
{
if shared_attachment_matches_current_user(attachment, state).await? {
handle_unavailable_shared_instance_if_current_user(
instance_id,
attachment,
reason,
state,
)
.await?;
return Err(shared_instance_unavailable_error(reason));
}
return Err(crate::ErrorKind::OtherError(format!(
"{}: {}",
shared_instance_unavailable_message(reason),
attachment.id
))
.into());
}
let version = match get_latest_remote_version_optional_unavailable(
&attachment.id,
state,
)
.await?
{
SharedInstanceRemoteResponse::Available(version) => version,
SharedInstanceRemoteResponse::Unavailable(reason) => {
if shared_attachment_matches_current_user(attachment, state).await?
{
handle_unavailable_shared_instance_if_current_user(
instance_id,
attachment,
reason,
state,
)
.await?;
return Err(shared_instance_unavailable_error(reason));
}
return Err(crate::ErrorKind::OtherError(format!(
"{}: {}",
shared_instance_unavailable_message(reason),
attachment.id
))
.into());
}
};
Ok(version)
}
pub(super) async fn shared_attachment_matches_current_user(
attachment: &SharedInstanceAttachment,
state: &State,
) -> crate::Result<bool> {
let Some(linked_user_id) = attachment.linked_user_id.as_deref() else {
return Ok(false);
};
Ok(linked_modrinth_user_id(state)
.await?
.as_deref()
.is_some_and(|current_user_id| current_user_id == linked_user_id))
}
pub(super) async fn has_shared_instance_recipients(
users: &SharedInstanceUsers,
attachment: &SharedInstanceAttachment,
has_pending_recipients: bool,
state: &State,
) -> crate::Result<bool> {
if has_pending_recipients || users.tokens > 0 {
return Ok(true);
}
let current_user_id = linked_modrinth_user_id(state).await?;
Ok(users.user_ids.iter().any(|user_id| {
Some(user_id.as_str()) != attachment.linked_user_id.as_deref()
&& Some(user_id.as_str()) != attachment.manager_id.as_deref()
&& Some(user_id.as_str()) != current_user_id.as_deref()
}))
}
pub(super) async fn handle_unavailable_shared_instance_if_current_user(
instance_id: &str,
attachment: &SharedInstanceAttachment,
reason: SharedInstanceUnavailableReason,
state: &State,
) -> crate::Result<()> {
if reason != SharedInstanceUnavailableReason::Quarantined
&& !shared_attachment_matches_current_user(attachment, state).await?
{
return Ok(());
}
match reason {
SharedInstanceUnavailableReason::Quarantined => {
crate::state::quarantine_shared_instance(instance_id, &state.pool)
.await?;
}
_ => {
crate::state::clear_shared_instance(instance_id, &state.pool)
.await?;
}
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(())
}
pub(super) fn shared_instance_unavailable_error(
reason: SharedInstanceUnavailableReason,
) -> crate::Error {
crate::ErrorKind::SharedInstanceUnavailable(reason).into()
}
pub(super) fn shared_instance_unavailable_message(
reason: SharedInstanceUnavailableReason,
) -> &'static str {
match reason {
SharedInstanceUnavailableReason::Deleted => {
"Shared instance was deleted"
}
SharedInstanceUnavailableReason::AccessRevoked => {
"Shared instance access was revoked"
}
SharedInstanceUnavailableReason::Quarantined => {
"Shared instance was quarantined"
}
}
}
fn shared_instance_external_file_data(
file: ExternalFileResponse,
) -> crate::Result<SharedInstanceExternalFileData> {
let file_size = file.file_size.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Shared instance external file {} is missing its size",
file.file_name
))
})?;
let file_size = u64::try_from(file_size).map_err(|_| {
crate::ErrorKind::InputError(format!(
"Shared instance external file {} has an invalid size",
file.file_name
))
})?;
Ok(SharedInstanceExternalFileData {
file_name: file.file_name,
file_type: file.file_type,
url: file.url,
file_size,
})
}
pub(super) async fn shared_instance_install_data(
shared_instance_id: &str,
manager_id: Option<String>,
server_manager_name: Option<String>,
server_manager_icon_url: Option<String>,
instance_icon_url: Option<String>,
name: String,
version: InstanceVersionResponse,
state: &State,
) -> crate::Result<SharedInstanceInstallData> {
if !version.ready {
return Err(crate::ErrorKind::InputError(
"Shared instance version is not ready to install".to_string(),
)
.into());
}
let name = shared_instance_name(name);
let linked_user_id = linked_modrinth_user_id(state).await?;
let modpack = shared_instance_install_modpack(&version, state).await?;
let modpack_version_id =
modpack.as_ref().map(|modpack| modpack.version_id.as_str());
let modrinth_ids = version
.modrinth_ids
.into_iter()
.filter(|id| Some(id.as_str()) != modpack_version_id)
.collect();
Ok(SharedInstanceInstallData {
shared_instance_id: shared_instance_id.to_string(),
manager_id,
server_manager_name,
server_manager_icon_url,
instance_icon_url,
linked_user_id,
name,
version: version.version,
modrinth_ids,
external_files: version
.external_files
.into_iter()
.map(shared_instance_external_file_data)
.collect::<crate::Result<Vec<_>>>()?,
modpack,
game_version: version.game_version,
loader: version.loader,
loader_version: shared_instance_loader_version(version.loader_version),
})
}
pub(super) async fn linked_modrinth_user_id(
state: &State,
) -> crate::Result<Option<String>> {
Ok(
ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
.await?
.map(|credentials| credentials.user_id),
)
}
@@ -0,0 +1,184 @@
const SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS: i32 = 604800;
const SHARED_INSTANCE_INVITE_MAX_USES: i32 = 10;
use super::client::*;
use super::install::*;
use super::publish::*;
use super::types::*;
use super::*;
#[tracing::instrument]
pub async fn get_shared_instance_users(
instance_id: &str,
) -> crate::Result<SharedInstanceUsers> {
let state = State::get().await?;
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
return Ok(SharedInstanceUsers::empty());
};
get_remote_users(&attachment.id, &state).await
}
#[tracing::instrument]
pub async fn invite_shared_instance_users(
instance_id: &str,
user_ids: Vec<String>,
) -> crate::Result<SharedInstanceUsers> {
let state = State::get().await?;
let (metadata, attachment) =
shared_instance_for_invites(instance_id, user_ids.len(), &state)
.await?;
ensure_owner(&attachment)?;
if !user_ids.is_empty() {
ensure_ready_remote_version_for_invite(
instance_id,
&attachment,
&state,
)
.await?;
update_remote_instance(
&attachment.id,
shared_instance_name(metadata.instance.name.clone()),
&state,
)
.await?;
tracing::info!(
instance_id,
shared_instance_id = %attachment.id,
user_count = user_ids.len(),
"Adding users to shared instance"
);
add_remote_users(&attachment.id, user_ids.clone(), &state).await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
get_remote_users(&attachment.id, &state).await
}
#[tracing::instrument(skip(replace_invite_id))]
pub async fn create_shared_instance_invite_link(
instance_id: &str,
max_age_seconds: Option<i32>,
max_uses: Option<i32>,
replace_invite_id: Option<String>,
) -> crate::Result<SharedInstanceInviteLink> {
let state = State::get().await?;
let (metadata, attachment) =
shared_instance_for_invites(instance_id, 0, &state).await?;
ensure_owner(&attachment)?;
ensure_ready_remote_version_for_invite(instance_id, &attachment, &state)
.await?;
update_remote_instance(
&attachment.id,
shared_instance_name(metadata.instance.name),
&state,
)
.await?;
let max_age_seconds =
max_age_seconds.unwrap_or(SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS);
let max_uses = max_uses.unwrap_or(SHARED_INSTANCE_INVITE_MAX_USES);
if max_age_seconds <= 0
|| max_age_seconds > SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS
{
return Err(crate::ErrorKind::InputError(
"Invite expiry must be between now and seven days".to_string(),
)
.into());
}
if max_uses <= 0 {
return Err(crate::ErrorKind::InputError(
"Invite max uses must be greater than zero".to_string(),
)
.into());
}
if let Some(invite_id) = replace_invite_id {
delete_remote_invite(&attachment.id, &invite_id, &state).await?;
}
let created_at = Utc::now();
let response = request_json::<CreateInstanceInviteResponse>(
"create_instance_invite",
Method::POST,
&format!("/instances/{}/invites", attachment.id),
Some(json!({
"max_age": max_age_seconds,
"max_uses": max_uses,
})),
&state,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(SharedInstanceInviteLink {
invite_id: response.id,
expires_at: created_at
+ chrono::Duration::seconds(i64::from(max_age_seconds)),
max_uses,
})
}
#[tracing::instrument]
pub async fn remove_shared_instance_users(
instance_id: &str,
user_ids: Vec<String>,
has_pending_recipients: bool,
) -> crate::Result<SharedInstanceUsers> {
let state = State::get().await?;
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
return Ok(SharedInstanceUsers::empty());
};
ensure_owner(&attachment)?;
if !user_ids.is_empty() {
remove_remote_users(&attachment.id, user_ids, &state).await?;
}
let remaining_users = get_remote_users(&attachment.id, &state).await?;
if !has_shared_instance_recipients(
&remaining_users,
&attachment,
has_pending_recipients,
&state,
)
.await?
{
delete_remote_instance(&attachment.id, &state).await?;
crate::state::clear_shared_instance(instance_id, &state.pool).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
return Ok(SharedInstanceUsers::empty());
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(remaining_users)
}
#[tracing::instrument]
pub async fn accept_pending_shared_instance_invite(
shared_instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
if accept_pending_remote_invite(shared_instance_id, &state).await? {
return Ok(());
}
Err(crate::ErrorKind::InputError(
"No pending invite found for shared instance".to_string(),
)
.into())
}
#[tracing::instrument]
pub async fn decline_pending_shared_instance_invite(
shared_instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
decline_pending_remote_invite(shared_instance_id, &state).await
}
@@ -0,0 +1,140 @@
use super::content_set_diff::{
ContentSetDiffEntry, ContentSetDiffKind, ContentSetDiffOptions,
ContentSetSnapshot, ContentSetSnapshotVersion, diff_content_sets,
};
use crate::SharedInstanceUnavailableReason;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::install::{
InstallJobSnapshot, SharedInstanceExternalFileData,
SharedInstanceInstallData,
};
use crate::state::instances::{InstanceLink, SharedInstanceAttachment};
use crate::state::{
AppliedContentSetPatch, CacheBehaviour, CachedEntry, ContentSetSyncStatus,
ContentSourceKind, EditInstance, ModLoader, ModrinthCredentials,
ProjectType, SharedInstanceRole, State,
};
use crate::util::fetch::{INSECURE_REQWEST_CLIENT, REQWEST_CLIENT};
use chrono::{DateTime, Utc};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::io::Read;
pub(crate) const CONFIG_BUNDLE_FILE_NAME: &str = "configs.zip";
pub(crate) const CONFIG_BUNDLE_FILE_TYPE: &str = "configs";
pub(crate) const CONFIG_SYNC_ENABLED: bool = true;
pub(crate) const CONFIG_DIRECTORY: &str = "config";
pub(crate) const MAX_CONFIG_BUNDLE_ENTRIES: usize = 4096;
pub(crate) const MAX_CONFIG_BUNDLE_FILE_SIZE: u64 = 16 * 1024 * 1024;
pub(crate) const MAX_CONFIG_BUNDLE_TOTAL_SIZE: u64 = 128 * 1024 * 1024;
pub(crate) const CONFIG_FILE_EXTENSIONS: [&str; 13] = [
"json",
"json5",
"jsonc",
"yml",
"yaml",
"css",
"toml",
"txt",
"ini",
"cfg",
"conf",
"properties",
"xml",
];
pub(crate) fn read_bounded_config_bundle_entry(
reader: impl Read,
declared_size: u64,
total_size: &mut u64,
) -> crate::Result<Vec<u8>> {
let remaining = MAX_CONFIG_BUNDLE_TOTAL_SIZE.saturating_sub(*total_size);
let entry_limit = MAX_CONFIG_BUNDLE_FILE_SIZE.min(remaining);
if declared_size > entry_limit {
return Err(crate::ErrorKind::InputError(
"Shared instance config bundle exceeds the uncompressed size limit"
.to_string(),
)
.into());
}
let capacity = usize::try_from(declared_size).map_err(|_| {
crate::ErrorKind::InputError(
"Shared instance config bundle entry is too large".to_string(),
)
})?;
let mut bytes = Vec::with_capacity(capacity);
reader
.take(entry_limit.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Failed to read shared instance config bundle: {error}"
))
})?;
let actual_size = bytes.len() as u64;
if actual_size > entry_limit {
return Err(crate::ErrorKind::InputError(
"Shared instance config bundle exceeds the uncompressed size limit"
.to_string(),
)
.into());
}
*total_size = total_size.checked_add(actual_size).ok_or_else(|| {
crate::ErrorKind::InputError(
"Shared instance config bundle size overflowed".to_string(),
)
})?;
Ok(bytes)
}
mod client;
mod diff;
mod install;
mod invites;
mod publish;
mod types;
pub(crate) use self::install::check_shared_instance_availability_before_launch;
pub(crate) use self::publish::sync_shared_instance_icon;
pub use self::install::{
accept_shared_instance_invite_for_install,
get_shared_instance_install_preview, get_shared_instance_update_preview,
install_shared_instance, update_shared_instance,
};
pub use self::invites::{
accept_pending_shared_instance_invite, create_shared_instance_invite_link,
decline_pending_shared_instance_invite, get_shared_instance_users,
invite_shared_instance_users, remove_shared_instance_users,
};
pub use self::publish::{
get_shared_instance_publish_preview, publish_shared_instance,
unlink_shared_instance, unpublish_shared_instance,
};
pub use self::types::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
SharedInstanceJoinType, SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
};
pub async fn can_active_user_use_shared_instances() -> crate::Result<bool> {
let state = State::get().await?;
let Some(credentials) =
ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
.await?
else {
return Ok(true);
};
let status =
client::get_user_blacklist_status(&credentials.user_id, &state).await?;
Ok(!status.blacklisted)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
use super::*;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SharedInstanceUsers {
pub user_ids: Vec<String>,
#[serde(default)]
pub users: Vec<SharedInstanceUser>,
#[serde(default)]
pub tokens: i32,
}
impl SharedInstanceUsers {
pub(super) fn empty() -> Self {
Self {
user_ids: Vec::new(),
users: Vec::new(),
tokens: 0,
}
}
pub(super) fn from_users(
users: Vec<SharedInstanceUser>,
tokens: i32,
) -> Self {
let user_ids = users.iter().map(|user| user.id.clone()).collect();
Self {
user_ids,
users,
tokens,
}
}
pub(super) fn from_user_ids(user_ids: Vec<String>) -> Self {
let users = user_ids
.iter()
.map(|user_id| SharedInstanceUser {
id: user_id.clone(),
joined_at: None,
join_type: SharedInstanceJoinType::Invite,
last_played: None,
})
.collect();
Self {
user_ids,
users,
tokens: 0,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SharedInstanceUser {
pub id: String,
pub joined_at: Option<DateTime<Utc>>,
pub join_type: SharedInstanceJoinType,
pub last_played: Option<DateTime<Utc>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SharedInstanceJoinType {
Owner,
Invite,
Link,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInstallPreview {
pub shared_instance_id: String,
pub version: i32,
pub name: String,
pub icon_url: Option<String>,
pub game_version: String,
pub loader: ModLoader,
pub mod_count: usize,
pub external_file_count: usize,
pub modpack_version_id: Option<String>,
pub content_version_ids: Vec<String>,
pub external_files: Vec<SharedInstanceExternalFilePreview>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceExternalFilePreview {
pub file_name: String,
pub file_type: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceUpdatePreview {
pub shared_instance_id: String,
pub current_version: Option<i32>,
pub latest_version: i32,
pub update_available: bool,
pub diffs: Vec<SharedInstanceUpdateDiff>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstancePublishPreview {
pub shared_instance_id: String,
pub latest_version: i32,
pub diffs: Vec<SharedInstanceUpdateDiff>,
pub config_files: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInviteLink {
pub invite_id: String,
pub expires_at: DateTime<Utc>,
pub max_uses: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInviteInstallPreview {
pub shared_instance_id: String,
pub manager_id: Option<String>,
pub server_manager_name: Option<String>,
pub server_manager_icon_url: Option<String>,
pub instance_icon_url: Option<String>,
pub preview: SharedInstanceInstallPreview,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceUpdateDiff {
#[serde(rename = "type")]
pub type_: SharedInstanceUpdateDiffType,
pub project_id: Option<String>,
pub project_name: Option<String>,
pub file_name: Option<String>,
pub current_version_name: Option<String>,
pub new_version_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_file_count: Option<usize>,
#[serde(default, skip_serializing_if = "is_false")]
pub disabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SharedInstanceUpdateDiffType {
Added,
Removed,
Updated,
ModpackLinked,
ModpackUpdated,
ModpackUnlinked,
GameVersionUpdated,
LoaderUpdated,
ConfigFilesUpdated,
}
pub(super) fn is_false(value: &bool) -> bool {
!*value
}
+4 -1
View File
@@ -11,9 +11,11 @@ pub mod minecraft_skins;
pub mod mr_auth;
pub mod pack;
pub mod process;
pub mod reports;
pub mod server_address;
pub mod settings;
pub mod tags;
pub mod users;
pub mod worlds;
pub mod data {
@@ -26,7 +28,8 @@ pub mod data {
JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project,
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize,
Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember,
Theme, User, UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
pub use modrinth_content_management::{
+13 -2
View File
@@ -1,8 +1,19 @@
use crate::state::ModrinthCredentials;
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModrinthAuthFlow {
SignIn,
SignUp,
}
#[tracing::instrument]
pub fn authenticate_begin_flow() -> &'static str {
crate::state::get_login_url()
pub fn authenticate_begin_flow(flow: ModrinthAuthFlow) -> &'static str {
match flow {
ModrinthAuthFlow::SignIn => crate::state::get_login_url(),
ModrinthAuthFlow::SignUp => crate::state::get_signup_url(),
}
}
#[tracing::instrument]
+46
View File
@@ -0,0 +1,46 @@
use crate::State;
use crate::util::fetch::fetch_json;
use reqwest::Method;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReportItemType {
Project,
Version,
User,
SharedInstance,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateReportRequest {
pub report_type: String,
pub item_id: String,
pub item_type: ReportItemType,
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub uploaded_images: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateReportResponse {
pub id: String,
}
#[tracing::instrument(skip(request))]
pub async fn create_report(
request: CreateReportRequest,
) -> crate::Result<CreateReportResponse> {
let state = State::get().await?;
fetch_json(
Method::POST,
&format!("{}report", env!("MODRINTH_API_URL_V3")),
None,
Some(serde_json::to_value(request)?),
Some("/v3/report"),
&state.api_semaphore,
&state.pool,
)
.await
}
+32
View File
@@ -0,0 +1,32 @@
use crate::State;
use crate::util::fetch::fetch_json;
use reqwest::Method;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchUser {
pub id: String,
pub username: String,
pub avatar_url: Option<String>,
}
#[tracing::instrument]
pub async fn search_user(query: &str) -> crate::Result<Vec<SearchUser>> {
let state = State::get().await?;
let query = urlencoding::encode(query);
fetch_json(
Method::GET,
&format!(
"{}users/search?query={}",
env!("MODRINTH_API_URL_V3"),
query
),
None,
None,
Some("/v3/users/search"),
&state.api_semaphore,
&state.pool,
)
.await
}
+21
View File
@@ -22,6 +22,24 @@ pub struct LabrinthError {
pub route: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SharedInstanceUnavailableReason {
Deleted,
AccessRevoked,
Quarantined,
}
impl std::fmt::Display for SharedInstanceUnavailableReason {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Deleted => write!(fmt, "deleted"),
Self::AccessRevoked => write!(fmt, "access_revoked"),
Self::Quarantined => write!(fmt, "quarantined"),
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum ErrorKind {
#[error("{0:?}")]
@@ -103,6 +121,9 @@ pub enum ErrorKind {
#[error("Invalid input: {0}")]
InputError(String),
#[error("Shared instance unavailable: {0}")]
SharedInstanceUnavailable(SharedInstanceUnavailableReason),
#[error("Join handle error: {0}")]
JoinError(#[from] tokio::task::JoinError),
+3
View File
@@ -234,6 +234,9 @@ pub enum CommandPayload {
server: Option<String>,
singleplayer_world: Option<String>,
},
InstallSharedInstanceInvite {
invite_id: String,
},
RunMRPack {
// run or install .mrpack
path: PathBuf,
+7 -4
View File
@@ -3,6 +3,7 @@ pub mod events;
pub mod model;
pub mod recovery;
pub mod runner;
mod shared_instance;
pub mod store;
pub use events::InstallProgressReporter;
@@ -11,11 +12,13 @@ pub use model::{
InstallJobEventKind, InstallJobKind, InstallJobSnapshot, InstallJobStatus,
InstallModpackPreview, InstallPhaseDetails, InstallPhaseId,
InstallPostInstallEdit, InstallProgress, InstallProgressSecondary,
InstallRequest,
InstallRequest, SharedInstanceExternalFileData, SharedInstanceInstallData,
SharedInstanceInstallModpack,
};
pub use runner::{
cancel_job, create_instance, create_modpack_instance, dismiss_job,
duplicate_instance, get_job, import_instance, install_existing_instance,
cancel_job, create_instance, create_modpack_instance,
create_shared_instance, dismiss_job, duplicate_instance, get_job,
import_instance, install_existing_instance,
install_pack_to_existing_instance, job_support_details, list_jobs,
retry_job,
retry_job, update_shared_instance,
};
+67 -2
View File
@@ -169,6 +169,9 @@ pub enum InstallRequest {
#[serde(default)]
post_install_edit: Option<InstallPostInstallEdit>,
},
CreateSharedInstance {
data: SharedInstanceInstallData,
},
ImportInstance {
launcher_type: ImportLauncherType,
base_path: PathBuf,
@@ -187,6 +190,10 @@ pub enum InstallRequest {
#[serde(default)]
post_install_edit: Option<InstallPostInstallEdit>,
},
UpdateSharedInstance {
instance_id: String,
data: SharedInstanceInstallData,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
@@ -201,6 +208,46 @@ pub struct InstallPostInstallEdit {
pub link: Option<InstanceLink>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceInstallData {
pub shared_instance_id: String,
pub manager_id: Option<String>,
#[serde(default)]
pub server_manager_name: Option<String>,
#[serde(default)]
pub server_manager_icon_url: Option<String>,
#[serde(default)]
pub instance_icon_url: Option<String>,
#[serde(default)]
pub linked_user_id: Option<String>,
pub name: String,
pub version: i32,
pub modrinth_ids: Vec<String>,
#[serde(default)]
pub external_files: Vec<SharedInstanceExternalFileData>,
pub modpack: Option<SharedInstanceInstallModpack>,
pub game_version: String,
pub loader: ModLoader,
pub loader_version: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceExternalFileData {
pub file_name: String,
pub file_type: String,
pub url: String,
pub file_size: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceInstallModpack {
pub project_id: String,
pub version_id: String,
pub title: String,
pub icon_url: Option<String>,
pub dependency_count: usize,
}
impl InstallRequest {
pub fn kind(&self) -> InstallJobKind {
match self {
@@ -208,6 +255,9 @@ impl InstallRequest {
Self::CreateModpackInstance { .. } => {
InstallJobKind::CreateModpackInstance
}
Self::CreateSharedInstance { .. } => {
InstallJobKind::CreateSharedInstance
}
Self::ImportInstance { .. } => InstallJobKind::ImportInstance,
Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance,
Self::InstallExistingInstance { .. } => {
@@ -216,13 +266,17 @@ impl InstallRequest {
Self::InstallPackToExistingInstance { .. } => {
InstallJobKind::InstallPackToExistingInstance
}
Self::UpdateSharedInstance { .. } => {
InstallJobKind::UpdateSharedInstance
}
}
}
pub fn target(&self) -> InstallTarget {
match self {
Self::InstallExistingInstance { instance_id, .. }
| Self::InstallPackToExistingInstance { instance_id, .. } => {
| Self::InstallPackToExistingInstance { instance_id, .. }
| Self::UpdateSharedInstance { instance_id, .. } => {
InstallTarget::ExistingInstance {
instance_id: instance_id.clone(),
}
@@ -234,7 +288,8 @@ impl InstallRequest {
pub fn cleanup(&self) -> InstallCleanup {
match self {
Self::InstallExistingInstance { instance_id, .. }
| Self::InstallPackToExistingInstance { instance_id, .. } => {
| Self::InstallPackToExistingInstance { instance_id, .. }
| Self::UpdateSharedInstance { instance_id, .. } => {
InstallCleanup::RestoreExistingInstance {
instance_id: instance_id.clone(),
}
@@ -249,10 +304,12 @@ impl InstallRequest {
pub enum InstallJobKind {
CreateInstance,
CreateModpackInstance,
CreateSharedInstance,
ImportInstance,
DuplicateInstance,
InstallExistingInstance,
InstallPackToExistingInstance,
UpdateSharedInstance,
}
impl InstallJobKind {
@@ -260,24 +317,28 @@ impl InstallJobKind {
match self {
Self::CreateInstance => "create_instance",
Self::CreateModpackInstance => "create_modpack_instance",
Self::CreateSharedInstance => "create_shared_instance",
Self::ImportInstance => "import_instance",
Self::DuplicateInstance => "duplicate_instance",
Self::InstallExistingInstance => "install_existing_instance",
Self::InstallPackToExistingInstance => {
"install_pack_to_existing_instance"
}
Self::UpdateSharedInstance => "update_shared_instance",
}
}
pub fn from_stored_str(value: &str) -> Self {
match value {
"create_modpack_instance" => Self::CreateModpackInstance,
"create_shared_instance" => Self::CreateSharedInstance,
"import_instance" => Self::ImportInstance,
"duplicate_instance" => Self::DuplicateInstance,
"install_existing_instance" => Self::InstallExistingInstance,
"install_pack_to_existing_instance" => {
Self::InstallPackToExistingInstance
}
"update_shared_instance" => Self::UpdateSharedInstance,
_ => Self::CreateInstance,
}
}
@@ -484,6 +545,8 @@ pub struct InstallErrorView {
pub phase: Option<InstallPhaseId>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<crate::SharedInstanceUnavailableReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<InstallApiErrorDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<InstallErrorContext>,
@@ -513,6 +576,7 @@ impl InstallErrorView {
code: code.to_string(),
phase: Some(phase),
message: error.to_string(),
reason: None,
api: match error.raw.as_ref() {
crate::ErrorKind::LabrinthError(error) => {
Some(InstallApiErrorDetails {
@@ -538,6 +602,7 @@ impl InstallErrorView {
code: code.to_string(),
phase: Some(phase),
message: message.into(),
reason: None,
api: None,
context: None,
}
+284 -8
View File
@@ -7,7 +7,257 @@ use super::model::{
use super::store;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::State;
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
use crate::state::{
ContentEntry, ContentSetRemoteRef, ContentSetRemoteRefType,
ContentSetSyncProvider, ContentSetSyncState, InstanceFile,
InstanceMetadata, State,
};
use async_walkdir::WalkDir;
use chrono::Utc;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;
const SHARED_INSTANCE_ROLLBACK_FILE: &str = "rollback.json";
const SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR: &str = "instance";
#[derive(Deserialize, Serialize)]
struct SharedInstanceUpdateRollback {
files: Vec<InstanceFile>,
entries: Vec<ContentEntry>,
}
pub(super) async fn prepare_shared_instance_update_backup(
job_id: Uuid,
metadata: &InstanceMetadata,
state: &State,
) -> crate::Result<PathBuf> {
let staging_dir = state
.directories
.metadata_dir()
.join("install_job_backups")
.join(job_id.to_string());
if tokio::fs::try_exists(&staging_dir).await? {
crate::util::io::remove_dir_all(&staging_dir).await?;
}
crate::util::io::create_dir_all(&staging_dir).await?;
let result = async {
let files = content_rows::get_instance_files(
&metadata.instance.id,
&state.pool,
)
.await?;
let entries = content_rows::get_content_entries(
&metadata.applied_content_set.id,
&state.pool,
)
.await?;
let snapshot = SharedInstanceUpdateRollback { files, entries };
let instance_path = state
.directories
.instances_dir()
.join(&metadata.instance.path);
copy_directory(
&instance_path,
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
state,
)
.await?;
crate::util::io::write(
staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE),
serde_json::to_vec(&snapshot)?,
)
.await?;
Ok::<(), crate::Error>(())
}
.await;
if result.is_err() {
let _ = crate::util::io::remove_dir_all(&staging_dir).await;
}
result?;
Ok(staging_dir)
}
pub(super) async fn clear_staging_dir(job_state: &InstallJobState) {
let Some(staging_dir) = &job_state.paths.staging_dir else {
return;
};
if let Err(error) = crate::util::io::remove_dir_all(staging_dir).await
&& error.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
path = %staging_dir.display(),
"Failed to remove install rollback backup: {error}"
);
}
}
async fn restore_shared_instance_update(
staging_dir: &Path,
rollback: &super::model::InstallRollbackState,
state: &State,
) -> crate::Result<()> {
let snapshot = serde_json::from_slice::<SharedInstanceUpdateRollback>(
&crate::util::io::read(staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE))
.await?,
)?;
let instance_path = state
.directories
.instances_dir()
.join(&rollback.instance.instance.path);
if tokio::fs::try_exists(&instance_path).await? {
crate::util::io::remove_dir_all(&instance_path).await?;
}
copy_directory(
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
&instance_path,
state,
)
.await?;
content_rows::restore_instance_content_snapshot(
&rollback.instance.instance.id,
&snapshot.files,
&snapshot.entries,
&state.pool,
)
.await?;
restore_instance_metadata(&rollback.instance, state).await?;
Ok(())
}
async fn restore_instance_metadata(
metadata: &InstanceMetadata,
state: &State,
) -> crate::Result<()> {
let content_set_id = metadata.applied_content_set.id.as_str();
let mut tx = state.pool.begin().await?;
instance_rows::update_instance(&metadata.instance, &mut tx).await?;
content_rows::update_content_set(&metadata.applied_content_set, &mut tx)
.await?;
instance_rows::upsert_instance_link(
&metadata.instance.id,
&metadata.link,
&mut tx,
)
.await?;
instance_rows::set_shared_instance_attachment(
&metadata.instance.id,
metadata.shared_instance.as_ref(),
&mut tx,
)
.await?;
instance_rows::replace_instance_groups(
&metadata.instance.id,
&metadata.groups,
&mut tx,
)
.await?;
instance_rows::upsert_instance_launch_overrides(
&metadata.launch_overrides,
&mut tx,
)
.await?;
content_rows::delete_content_set_remote_ref(
content_set_id,
ContentSetRemoteRefType::SharedContentSet,
&mut tx,
)
.await?;
content_rows::delete_content_set_sync_state(content_set_id, &mut tx)
.await?;
if let Some(attachment) = &metadata.shared_instance {
content_rows::upsert_content_set_remote_ref(
&ContentSetRemoteRef {
content_set_id: content_set_id.to_string(),
ref_type: ContentSetRemoteRefType::SharedContentSet,
ref_id: attachment.id.clone(),
},
&mut tx,
)
.await?;
content_rows::upsert_content_set_sync_state(
&ContentSetSyncState {
content_set_id: content_set_id.to_string(),
provider: ContentSetSyncProvider::SharedInstance,
applied_update_id: attachment
.applied_version
.map(|value| value.to_string()),
latest_available_update_id: attachment
.latest_version
.map(|value| value.to_string()),
checked_at: Some(Utc::now()),
status: attachment.status,
},
&mut tx,
)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn copy_directory(
source: &Path,
target: &Path,
state: &State,
) -> crate::Result<()> {
crate::util::io::create_dir_all(target).await?;
let mut walker = WalkDir::new(source);
while let Some(entry) = walker.next().await {
let entry = entry.map_err(|error| {
crate::ErrorKind::FSError(format!(
"Failed to read instance backup path: {error}"
))
})?;
let entry_path = entry.path();
let relative_path = entry_path.strip_prefix(source)?;
let target_path = target.join(relative_path);
let file_type = entry.file_type().await?;
if file_type.is_dir() {
crate::util::io::create_dir_all(&target_path).await?;
} else if file_type.is_file() {
crate::util::fetch::copy(
&entry_path,
&target_path,
&state.io_semaphore,
)
.await?;
} else if file_type.is_symlink() {
copy_symlink(&entry_path, &target_path).await?;
}
}
Ok(())
}
async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
if let Some(parent) = target.parent() {
crate::util::io::create_dir_all(parent).await?;
}
let link_target = tokio::fs::read_link(source).await?;
#[cfg(unix)]
tokio::fs::symlink(link_target, target).await?;
#[cfg(windows)]
{
let metadata = tokio::fs::metadata(source).await?;
if metadata.is_dir() {
tokio::fs::symlink_dir(link_target, target).await?;
} else {
tokio::fs::symlink_file(link_target, target).await?;
}
}
Ok(())
}
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
let jobs = store::list_interrupted_candidates(state).await?;
@@ -61,6 +311,9 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
state,
)
.await?;
if job.state.rollback_error.is_none() {
clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
}
@@ -96,6 +349,15 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
..
} => None,
},
InstallRequest::CreateSharedInstance { data } => {
Some(InstallJobDisplay {
title: data.name.clone(),
icon: data
.modpack
.as_ref()
.and_then(|modpack| modpack.icon_url.clone()),
})
}
InstallRequest::ImportInstance {
instance_folder, ..
} => Some(InstallJobDisplay {
@@ -104,7 +366,8 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
}),
InstallRequest::DuplicateInstance { .. }
| InstallRequest::InstallExistingInstance { .. }
| InstallRequest::InstallPackToExistingInstance { .. } => {
| InstallRequest::InstallPackToExistingInstance { .. }
| InstallRequest::UpdateSharedInstance { .. } => {
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
title: rollback.instance.instance.name.clone(),
icon: rollback.instance.instance.icon_path.clone(),
@@ -128,12 +391,25 @@ pub async fn apply_cleanup(
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
if let Some(rollback) = &job_state.rollback {
crate::state::instances::commands::set_instance_install_stage(
instance_id,
rollback.install_stage,
&state.pool,
)
.await?;
if matches!(
&job_state.request,
InstallRequest::UpdateSharedInstance { .. }
) && let Some(staging_dir) = &job_state.paths.staging_dir
{
restore_shared_instance_update(
staging_dir,
rollback,
state,
)
.await?;
} else {
crate::state::instances::commands::set_instance_install_stage(
instance_id,
rollback.install_stage,
&state.pool,
)
.await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
}
+262 -16
View File
@@ -3,7 +3,13 @@ use super::model::{
InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay,
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit,
InstallRequest, InstallRollbackState, InstallTarget,
InstallProgress, InstallRequest, InstallRollbackState, InstallTarget,
SharedInstanceInstallData,
};
use super::shared_instance::{
apply_shared_instance_content, apply_shared_instance_update,
attach_pending_shared_instance, finalize_shared_instance_attachment,
shared_instance_link, shared_instance_pack_location,
};
use super::{diagnostics, recovery, store};
use crate::ErrorKind;
@@ -53,6 +59,19 @@ pub async fn create_modpack_instance(
.await
}
pub async fn create_shared_instance(
data: SharedInstanceInstallData,
) -> crate::Result<InstallJobSnapshot> {
start(InstallRequest::CreateSharedInstance { data }).await
}
pub async fn update_shared_instance(
instance_id: String,
data: SharedInstanceInstallData,
) -> crate::Result<InstallJobSnapshot> {
start(InstallRequest::UpdateSharedInstance { instance_id, data }).await
}
pub async fn import_instance(
launcher_type: crate::api::pack::import::ImportLauncherType,
base_path: PathBuf,
@@ -129,9 +148,15 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
.into());
}
if job.state.rollback_error.is_some() {
recovery::apply_cleanup(&job.state, &state).await?;
recovery::clear_staging_dir(&job.state).await;
}
job.state.target = job.state.request.target();
job.state.cleanup = job.state.request.cleanup();
job.state.rollback = None;
job.state.paths.staging_dir = None;
job.state.error = None;
job.state.rollback_error = None;
job.state.context = None;
@@ -150,6 +175,7 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
&state,
)
.await?;
lock_existing_instance_if_needed(&job.state, &state).await?;
emit_install_job(&record.snapshot()).await?;
spawn_job(job_id);
@@ -204,6 +230,9 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
&state,
)
.await?;
if job.state.rollback_error.is_none() {
recovery::clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
Ok(record.snapshot())
@@ -221,6 +250,7 @@ async fn start(request: InstallRequest) -> crate::Result<InstallJobSnapshot> {
prepare_initial_instance(&mut job_state, &state).await?;
let record =
store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?;
lock_existing_instance_if_needed(&job_state, &state).await?;
emit_install_job(&record.snapshot()).await?;
spawn_job(id);
Ok(record.snapshot())
@@ -296,6 +326,54 @@ async fn prepare_initial_instance(
);
set_instance_id(job_state, metadata.instance.id);
}
InstallRequest::CreateSharedInstance { data } => {
let shared_link = shared_instance_link(data.modpack.as_ref());
let (game_version, loader, loader_version, icon_path) =
if let Some(modpack) = data.modpack.clone() {
let preview = get_instance_from_pack(
shared_instance_pack_location(modpack),
)
.await?;
(
preview.game_version,
preview.modloader,
preview.loader_version,
data.instance_icon_url
.clone()
.or_else(|| {
preview.icon.as_ref().map(|path| {
path.to_string_lossy().to_string()
})
})
.or_else(|| preview.icon_url.clone()),
)
} else {
(
data.game_version.clone(),
data.loader,
data.loader_version.clone(),
data.instance_icon_url.clone(),
)
};
let metadata = crate::api::instance::create(
data.name.clone(),
game_version,
loader,
loader_version,
icon_path,
shared_link,
)
.await?;
set_display(
job_state,
metadata.instance.name,
metadata.instance.icon_path,
);
let instance_id = metadata.instance.id;
attach_pending_shared_instance(&instance_id, &data, state).await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
set_instance_id(job_state, instance_id);
}
InstallRequest::ImportInstance {
instance_folder, ..
} => {
@@ -343,7 +421,8 @@ async fn prepare_initial_instance(
InstallRequest::InstallExistingInstance { instance_id, .. }
| InstallRequest::InstallPackToExistingInstance {
instance_id, ..
} => {
}
| InstallRequest::UpdateSharedInstance { instance_id, .. } => {
prepare_existing_rollback(job_state, state, &instance_id).await?;
}
}
@@ -353,7 +432,7 @@ async fn prepare_initial_instance(
fn spawn_job(job_id: Uuid) {
tokio::spawn(async move {
if let Err(error) = run_job(job_id).await {
if let Err(error) = Box::pin(run_job(job_id)).await {
tracing::error!(
"Install job {job_id} failed to update state: {error}"
);
@@ -387,16 +466,23 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
.await?;
emit_install_job(&record.snapshot()).await?;
let result = run_request(job_id, &mut job_state, &state).await;
let result = Box::pin(run_request(job_id, &mut job_state, &state)).await;
if let Ok(record) = store::get_required(job_id, &state).await {
job_state = record.state;
}
match result {
let result = match result {
Ok(instance_id) => {
if let Some(instance_id) = instance_id {
set_instance_id(&mut job_state, instance_id);
}
finalize_existing_instance_success(&job_state, &state).await
}
Err(error) => Err(error),
};
match result {
Ok(()) => {
job_state.record_event(InstallJobEventKind::JobSucceeded {
instance_id: current_instance_id(&job_state),
});
@@ -413,6 +499,7 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
&state,
)
.await?;
recovery::clear_staging_dir(&job_state).await;
emit_install_job(&record.snapshot()).await?;
}
Err(error) => {
@@ -459,6 +546,9 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
&state,
)
.await?;
if job_state.rollback_error.is_none() {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
return Err(error);
}
@@ -541,17 +631,39 @@ async fn run_request(
modpack_details(&location),
)
.await?;
install_pack(
Box::pin(install_pack(
job_id,
job_state,
location,
instance_id.clone(),
DownloadReason::Modpack,
)
))
.await?;
apply_post_install_edit(&instance_id, post_install_edit).await?;
Ok(Some(instance_id))
}
InstallRequest::CreateSharedInstance { data } => {
let Some(instance_id) = current_instance_id(job_state) else {
return Err(crate::ErrorKind::InputError(
"Install job is missing its instance id".to_string(),
)
.into());
};
Box::pin(apply_shared_instance_content(
job_id,
job_state,
state,
&instance_id,
&data,
))
.await?;
finalize_shared_instance_attachment(&instance_id, &data, state)
.await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
Ok(Some(instance_id))
}
InstallRequest::ImportInstance {
launcher_type,
base_path,
@@ -629,6 +741,7 @@ async fn run_request(
}
InstallRequest::InstallExistingInstance { instance_id, force } => {
prepare_existing_rollback(job_state, state, &instance_id).await?;
lock_existing_instance(&instance_id, state).await?;
update_progress(
job_id,
job_state,
@@ -660,6 +773,7 @@ async fn run_request(
post_install_edit,
} => {
prepare_existing_rollback(job_state, state, &instance_id).await?;
lock_existing_instance(&instance_id, state).await?;
let disabled_project_ids = remove_existing_pack_content(
job_id,
job_state,
@@ -667,13 +781,13 @@ async fn run_request(
&instance_id,
)
.await?;
install_pack(
Box::pin(install_pack(
job_id,
job_state,
location,
instance_id.clone(),
DownloadReason::Modpack,
)
))
.await?;
restore_disabled_projects(
&instance_id,
@@ -684,6 +798,49 @@ async fn run_request(
apply_post_install_edit(&instance_id, post_install_edit).await?;
Ok(Some(instance_id))
}
InstallRequest::UpdateSharedInstance { instance_id, data } => {
prepare_existing_rollback(job_state, state, &instance_id).await?;
lock_existing_instance(&instance_id, state).await?;
let rollback_instance = job_state
.rollback
.as_ref()
.map(|rollback| rollback.instance.clone())
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"Shared instance update rollback state is missing"
.to_string(),
)
})?;
let staging_dir = recovery::prepare_shared_instance_update_backup(
job_id,
&rollback_instance,
state,
)
.await?;
job_state.paths.staging_dir = Some(staging_dir);
let record = store::update_state(job_id, job_state, state).await?;
emit_install_job(&record.snapshot()).await?;
let disabled_project_ids =
disabled_project_ids(&instance_id, state).await?;
Box::pin(apply_shared_instance_update(
job_id,
job_state,
state,
&instance_id,
&data,
))
.await?;
restore_disabled_projects(
&instance_id,
disabled_project_ids,
state,
)
.await?;
finalize_shared_instance_attachment(&instance_id, &data, state)
.await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
Ok(Some(instance_id))
}
}
}
@@ -714,6 +871,20 @@ async fn apply_post_install_edit(
Ok(())
}
async fn disabled_project_ids(
instance_id: &str,
state: &State,
) -> crate::Result<HashSet<String>> {
Ok(crate::state::instances::commands::list_project_files(
instance_id,
state,
)
.await?
.into_iter()
.filter_map(|file| (!file.enabled).then_some(file.project_id?))
.collect())
}
async fn remove_existing_pack_content(
job_id: Uuid,
job_state: &InstallJobState,
@@ -873,7 +1044,7 @@ async fn restore_disabled_projects(
Ok(())
}
async fn install_pack(
pub(super) async fn install_pack(
job_id: Uuid,
job_state: &mut InstallJobState,
location: CreatePackLocation,
@@ -927,12 +1098,12 @@ async fn install_pack(
}
};
install_zipped_mrpack_files_with_reporter(
Box::pin(install_zipped_mrpack_files_with_reporter(
create_pack,
false,
reason,
reporter,
)
))
.await?;
Ok(())
@@ -954,6 +1125,12 @@ async fn prepare_existing_rollback(
"Unknown instance {instance_id}"
))
})?;
if instance.quarantined {
return Err(crate::ErrorKind::InputError(
"Content in quarantined instances cannot be changed.".to_string(),
)
.into());
}
let install_stage = instance.instance.install_stage;
set_display(
job_state,
@@ -968,6 +1145,26 @@ async fn prepare_existing_rollback(
instance_id: instance_id.to_string(),
};
Ok(())
}
async fn lock_existing_instance_if_needed(
job_state: &InstallJobState,
state: &State,
) -> crate::Result<()> {
if let InstallCleanup::RestoreExistingInstance { instance_id } =
&job_state.cleanup
{
lock_existing_instance(instance_id, state).await?;
}
Ok(())
}
async fn lock_existing_instance(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
crate::state::instances::commands::set_instance_install_stage(
instance_id,
InstanceInstallStage::MinecraftInstalling,
@@ -979,7 +1176,26 @@ async fn prepare_existing_rollback(
Ok(())
}
async fn update_progress(
async fn finalize_existing_instance_success(
job_state: &InstallJobState,
state: &State,
) -> crate::Result<()> {
if let InstallCleanup::RestoreExistingInstance { instance_id } =
&job_state.cleanup
{
crate::state::instances::commands::set_instance_install_stage(
instance_id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
Ok(())
}
pub(super) async fn update_progress(
job_id: Uuid,
job_state: &mut InstallJobState,
state: &State,
@@ -992,6 +1208,25 @@ async fn update_progress(
Ok(())
}
pub(super) async fn update_content_progress(
job_id: Uuid,
job_state: &mut InstallJobState,
state: &State,
current: u64,
total: u64,
) -> crate::Result<()> {
job_state.progress.phase = InstallPhaseId::DownloadingContent;
job_state.progress.progress = Some(InstallProgress {
current,
total,
secondary: None,
});
job_state.progress.details = InstallPhaseDetails::Empty;
let record = store::update_state(job_id, job_state, state).await?;
emit_install_job(&record.snapshot()).await?;
Ok(())
}
fn set_instance_id(job_state: &mut InstallJobState, instance_id: String) {
job_state.target = match &job_state.target {
InstallTarget::ExistingInstance { .. } => {
@@ -1036,12 +1271,16 @@ fn install_error_view(
error: &crate::Error,
context: Option<InstallErrorContext>,
) -> InstallErrorView {
InstallErrorView::from_error(
let mut view = InstallErrorView::from_error(
install_error_code(phase, error),
phase,
error,
context,
)
);
if let ErrorKind::SharedInstanceUnavailable(reason) = error.raw.as_ref() {
view.reason = Some(*reason);
}
view
}
fn install_error_code(
@@ -1051,6 +1290,9 @@ fn install_error_code(
use InstallPhaseId::*;
match error.raw.as_ref() {
ErrorKind::SharedInstanceUnavailable(_) => {
"shared_instance_unavailable"
}
ErrorKind::InputError(_) => match phase {
PreparingInstance | Finalizing => "instance_error",
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
@@ -1079,6 +1321,8 @@ fn install_error_code(
},
ErrorKind::FetchError(_)
| ErrorKind::ApiIsDownError(_)
| ErrorKind::WSError(_)
| ErrorKind::WSClosedError(_)
| ErrorKind::Ratelimited { .. } => "network_error",
ErrorKind::Any(_)
if matches!(
@@ -1123,7 +1367,9 @@ fn current_instance_id(job_state: &InstallJobState) -> Option<String> {
}
}
fn modpack_details(location: &CreatePackLocation) -> InstallPhaseDetails {
pub(super) fn modpack_details(
location: &CreatePackLocation,
) -> InstallPhaseDetails {
match location {
CreatePackLocation::FromVersionId {
project_id,
File diff suppressed because it is too large Load Diff
+30 -1
View File
@@ -290,6 +290,7 @@ pub async fn install_minecraft_with_reporter(
};
let state = State::get().await?;
let previous_install_stage = instance.install_stage;
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
@@ -299,6 +300,7 @@ pub async fn install_minecraft_with_reporter(
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
let result = async {
let instance_path = get_instance_full_path(&instance.path).await?;
if let Some(reporter) = &reporter {
reporter
@@ -618,7 +620,34 @@ pub async fn install_minecraft_with_reporter(
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
}
Ok(())
Ok::<(), crate::Error>(())
}
.await;
if result.is_err() {
if let Err(error) =
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
previous_install_stage,
&state.pool,
)
.await
{
tracing::error!(
"Failed to restore install stage for instance {}: {error}",
instance.id
);
} else if let Err(error) =
emit_instance(&instance.id, InstancePayloadType::Edited).await
{
tracing::error!(
"Failed to emit restored install stage for instance {}: {error}",
instance.id
);
}
}
result
}
pub async fn install_minecraft_for_instance_id_with_reporter(
+5
View File
@@ -240,6 +240,11 @@ impl FriendsSocket {
}
async fn handle_notification(notification: Value) -> crate::Result<()> {
tracing::info!(
notification = %notification,
"Received websocket notification payload"
);
if notification
.get("body")
.and_then(|body| body.get("type"))
+16 -1
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use super::instances::ContentSourceKind;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum InstanceInstallStage {
@@ -122,6 +124,7 @@ pub struct ContentFile {
pub metadata: Option<FileMetadata>,
pub update_version_id: Option<String>,
pub project_type: ProjectType,
pub source_kind: Option<ContentSourceKind>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -130,7 +133,7 @@ pub struct FileMetadata {
pub version_id: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum ProjectType {
Mod,
@@ -186,6 +189,18 @@ impl ProjectType {
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"mod" | "mods" => Some(ProjectType::Mod),
"datapack" | "datapacks" => Some(ProjectType::DataPack),
"resourcepack" | "resourcepacks" => Some(ProjectType::ResourcePack),
"shader" | "shaderpack" | "shaderpacks" => {
Some(ProjectType::ShaderPack)
}
_ => None,
}
}
pub fn get_folder(&self) -> &'static str {
match self {
ProjectType::Mod => "mods",
@@ -89,6 +89,9 @@ fn is_scannable_project_file(
ProjectType::Mod => extension.eq_ignore_ascii_case("jar"),
ProjectType::DataPack
| ProjectType::ResourcePack
| ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"),
| ProjectType::ShaderPack => {
extension.eq_ignore_ascii_case("zip")
|| extension.eq_ignore_ascii_case("jar")
}
}
}
@@ -374,6 +374,56 @@ where
rows.into_iter().map(TryInto::try_into).collect()
}
pub(crate) async fn upsert_content_set_remote_ref(
remote_ref: &ContentSetRemoteRef,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let content_set_id = remote_ref.content_set_id.as_str();
let ref_type = remote_ref.ref_type.as_str();
let ref_id = remote_ref.ref_id.as_str();
sqlx::query!(
"
INSERT INTO instance_content_set_remote_refs (
content_set_id,
ref_type,
ref_id
)
VALUES (?, ?, ?)
ON CONFLICT (content_set_id, ref_type) DO UPDATE SET
ref_id = excluded.ref_id
",
content_set_id,
ref_type,
ref_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn delete_content_set_remote_ref(
content_set_id: &str,
ref_type: ContentSetRemoteRefType,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let ref_type = ref_type.as_str();
sqlx::query!(
"
DELETE FROM instance_content_set_remote_refs
WHERE content_set_id = ? AND ref_type = ?
",
content_set_id,
ref_type,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn get_content_set_sync_state<'e, E>(
content_set_id: &str,
exec: E,
@@ -396,6 +446,66 @@ where
row.map(TryInto::try_into).transpose()
}
pub(crate) async fn upsert_content_set_sync_state(
sync_state: &ContentSetSyncState,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let content_set_id = sync_state.content_set_id.as_str();
let provider = sync_state.provider.as_str();
let applied_update_id = sync_state.applied_update_id.as_deref();
let latest_available_update_id =
sync_state.latest_available_update_id.as_deref();
let checked_at = sync_state.checked_at.map(|value| value.timestamp());
let status = sync_state.status.as_str();
sqlx::query!(
"
INSERT INTO instance_content_set_sync_state (
content_set_id,
provider,
applied_update_id,
latest_available_update_id,
checked_at,
status
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (content_set_id) DO UPDATE SET
provider = excluded.provider,
applied_update_id = excluded.applied_update_id,
latest_available_update_id = excluded.latest_available_update_id,
checked_at = excluded.checked_at,
status = excluded.status
",
content_set_id,
provider,
applied_update_id,
latest_available_update_id,
checked_at,
status,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn delete_content_set_sync_state(
content_set_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
sqlx::query!(
"
DELETE FROM instance_content_set_sync_state
WHERE content_set_id = ?
",
content_set_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn get_instance_files<'e, E>(
instance_id: &str,
exec: E,
@@ -523,6 +633,100 @@ pub(crate) async fn upsert_instance_file(
row.try_into()
}
async fn insert_content_entry(
entry: &ContentEntry,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let id = entry.id.as_str();
let instance_id = entry.instance_id.as_str();
let content_set_id = entry.content_set_id.as_str();
let file_id = entry.file_id.as_deref();
let project_type = entry.project_type.get_name();
let project_id = entry.project_id.as_deref();
let version_id = entry.version_id.as_deref();
let source_kind = entry.source_kind.as_str();
let server_requirement = entry.server_requirement.as_str();
let client_requirement = entry.client_requirement.as_str();
let enabled = i64::from(entry.enabled);
let added_at = entry.added_at.timestamp();
let modified_at = entry.modified_at.timestamp();
sqlx::query(
"
INSERT INTO instance_content_entries (
id,
instance_id,
content_set_id,
file_id,
project_type,
project_id,
version_id,
source_kind,
server_requirement,
client_requirement,
enabled,
added_at,
modified_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
",
)
.bind(id)
.bind(instance_id)
.bind(content_set_id)
.bind(file_id)
.bind(project_type)
.bind(project_id)
.bind(version_id)
.bind(source_kind)
.bind(server_requirement)
.bind(client_requirement)
.bind(enabled)
.bind(added_at)
.bind(modified_at)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn restore_instance_content_snapshot(
instance_id: &str,
files: &[InstanceFile],
entries: &[ContentEntry],
pool: &SqlitePool,
) -> crate::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query(
"
DELETE FROM instance_content_entries
WHERE instance_id = ?
",
)
.bind(instance_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"
DELETE FROM instance_files
WHERE instance_id = ?
",
)
.bind(instance_id)
.execute(&mut *tx)
.await?;
for file in files {
upsert_instance_file(file, &mut tx).await?;
}
for entry in entries {
insert_content_entry(entry, &mut tx).await?;
}
tx.commit().await?;
Ok(())
}
pub(crate) async fn get_content_entries<'e, E>(
content_set_id: &str,
exec: E,
@@ -1091,16 +1295,12 @@ pub(crate) async fn upsert_content_update_check(
}
fn project_type_from_str(value: &str) -> crate::Result<ProjectType> {
match value {
"mod" => Ok(ProjectType::Mod),
"datapack" => Ok(ProjectType::DataPack),
"resourcepack" => Ok(ProjectType::ResourcePack),
"shader" | "shaderpack" => Ok(ProjectType::ShaderPack),
other => Err(crate::ErrorKind::InputError(format!(
"Unknown content project type {other}"
ProjectType::from_name(value).ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown content project type {value}"
))
.into()),
}
.into()
})
}
fn timestamp(value: i64) -> DateTime<Utc> {
@@ -1,9 +1,10 @@
#![allow(dead_code)]
use crate::state::instances::{
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
InstanceLaunchContext, InstanceLaunchOverrides,
InstanceLaunchOverridesData, InstanceLink, playtime_to_storage,
ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind,
Instance, InstanceLaunchContext, InstanceLaunchOverrides,
InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment,
SharedInstanceRole, playtime_to_storage,
};
use crate::state::{
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
@@ -73,6 +74,9 @@ pub(crate) struct InstanceLinkRow {
pub hosting_instance_ids: Option<String>,
pub hosting_active_instance_id: Option<String>,
pub shared_instance_id: Option<String>,
pub shared_instance_role: Option<String>,
pub shared_instance_manager_id: Option<String>,
pub shared_instance_linked_user_id: Option<String>,
pub imported_name: Option<String>,
pub imported_version_number: Option<String>,
pub imported_filename: Option<String>,
@@ -137,10 +141,8 @@ impl TryFrom<InstanceLinkRow> for InstanceLink {
filename: row.imported_filename,
}),
"shared_instance" => Ok(Self::SharedInstance {
shared_instance_id: parse_uuid(
row.shared_instance_id,
"shared_instance_id",
)?,
modpack_project_id: row.modrinth_project_id,
modpack_version_id: row.modrinth_version_id,
}),
other => Err(crate::ErrorKind::InputError(format!(
"Unknown instance link kind {other}"
@@ -161,6 +163,7 @@ pub(crate) struct InstanceMetadataRecord {
pub instance: Instance,
pub applied_content_set: ContentSet,
pub link: InstanceLink,
pub shared_instance: Option<SharedInstanceAttachment>,
pub groups: Vec<String>,
pub launch_overrides: InstanceLaunchOverrides,
}
@@ -207,6 +210,14 @@ struct InstanceMetadataRow {
hosting_instance_ids: Option<String>,
hosting_active_instance_id: Option<String>,
shared_instance_id: Option<String>,
shared_instance_role: Option<String>,
shared_instance_manager_id: Option<String>,
shared_instance_server_manager_name: Option<String>,
shared_instance_server_manager_icon_url: Option<String>,
shared_instance_linked_user_id: Option<String>,
shared_sync_applied_update_id: Option<String>,
shared_sync_latest_available_update_id: Option<String>,
shared_sync_status: Option<String>,
imported_name: Option<String>,
imported_version_number: Option<String>,
imported_filename: Option<String>,
@@ -300,12 +311,28 @@ impl InstanceMetadataRow {
hosting_server_id: self.hosting_server_id,
hosting_instance_ids: self.hosting_instance_ids,
hosting_active_instance_id: self.hosting_active_instance_id,
shared_instance_id: self.shared_instance_id,
shared_instance_id: self.shared_instance_id.clone(),
shared_instance_role: self.shared_instance_role.clone(),
shared_instance_manager_id: self.shared_instance_manager_id.clone(),
shared_instance_linked_user_id: self
.shared_instance_linked_user_id
.clone(),
imported_name: self.imported_name,
imported_version_number: self.imported_version_number,
imported_filename: self.imported_filename,
}
.try_into()?;
let shared_instance = shared_instance_attachment(
self.shared_instance_id,
self.shared_instance_role,
self.shared_instance_manager_id,
self.shared_instance_server_manager_name,
self.shared_instance_server_manager_icon_url,
self.shared_instance_linked_user_id,
self.shared_sync_status,
self.shared_sync_applied_update_id,
self.shared_sync_latest_available_update_id,
)?;
let groups = parse_groups(self.groups)?;
let launch_overrides =
launch_overrides_from_json(instance_id, self.launch_overrides)?;
@@ -314,6 +341,7 @@ impl InstanceMetadataRow {
instance,
applied_content_set,
link,
shared_instance,
groups,
launch_overrides,
})
@@ -418,75 +446,173 @@ where
Ok(row)
}
pub(crate) async fn is_instance_quarantined<'e, E>(
instance_id: &str,
exec: E,
) -> crate::Result<bool>
where
E: Executor<'e, Database = Sqlite>,
{
let quarantined = sqlx::query_scalar::<_, bool>(
"
SELECT EXISTS (
SELECT 1
FROM instance_quarantines
WHERE instance_id = ?
)
",
)
.bind(instance_id)
.fetch_one(exec)
.await?;
Ok(quarantined)
}
pub(crate) async fn get_quarantined_instance_ids<'e, E>(
exec: E,
) -> crate::Result<std::collections::HashSet<String>>
where
E: Executor<'e, Database = Sqlite>,
{
let instance_ids = sqlx::query_scalar::<_, String>(
"
SELECT instance_id
FROM instance_quarantines
",
)
.fetch_all(exec)
.await?;
Ok(instance_ids.into_iter().collect())
}
pub(crate) async fn set_instance_quarantined(
instance_id: &str,
quarantined: bool,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
if quarantined {
sqlx::query(
"
INSERT INTO instance_quarantines (instance_id)
VALUES (?)
ON CONFLICT (instance_id) DO NOTHING
",
)
.bind(instance_id)
.execute(&mut **tx)
.await?;
} else {
sqlx::query(
"
DELETE FROM instance_quarantines
WHERE instance_id = ?
",
)
.bind(instance_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}
macro_rules! query_instance_metadata {
(
$prefix:literal,
$from:literal,
$suffix:literal,
$arg:expr $(,)?
) => {
sqlx::query_as!(
InstanceMetadataRow,
$prefix
+ r#"
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.shared_instance_role AS "shared_instance_role?: String",
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
link.shared_instance_server_manager_name AS "shared_instance_server_manager_name?: String",
link.shared_instance_server_manager_icon_url AS "shared_instance_server_manager_icon_url?: String",
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
sync.status AS "shared_sync_status?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
COALESCE((
SELECT json_group_array(group_name)
FROM (
SELECT group_name
FROM instance_groups
WHERE instance_id = i.id
ORDER BY group_name
)
), '[]') AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
"#
+ $from
+ r#"
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_content_set_sync_state sync
ON sync.content_set_id = cs.id
AND sync.provider = 'shared_instance'
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
"#
+ $suffix,
$arg,
)
};
}
pub(crate) async fn get_instance_metadata_by_id(
id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceMetadataRecord>> {
let row = sqlx::query_as!(
InstanceMetadataRow,
r#"
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
COALESCE((
SELECT json_group_array(group_name)
FROM (
SELECT group_name
FROM instance_groups
WHERE instance_id = i.id
ORDER BY group_name
)
), '[]') AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
FROM instances i
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
WHERE i.id = ?
"#,
id,
)
.fetch_optional(pool)
.await?;
let row =
query_instance_metadata!("", "FROM instances i", "WHERE i.id = ?", id,)
.fetch_optional(pool)
.await?;
row.map(InstanceMetadataRow::into_record).transpose()
}
@@ -500,73 +626,19 @@ pub(crate) async fn get_instance_metadata_many(
}
let ids_json = serde_json::to_string(ids)?;
let rows = sqlx::query_as!(
InstanceMetadataRow,
let rows = query_instance_metadata!(
r#"
WITH requested AS (
SELECT value AS id, key AS ord
FROM json_each(?)
)
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
COALESCE((
SELECT json_group_array(group_name)
FROM (
SELECT group_name
FROM instance_groups
WHERE instance_id = i.id
ORDER BY group_name
)
), '[]') AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
"#,
r#"
FROM requested
INNER JOIN instances i
ON i.id = requested.id
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
ORDER BY requested.ord
"#,
"ORDER BY requested.ord",
ids_json,
)
.fetch_all(pool)
@@ -580,69 +652,10 @@ pub(crate) async fn get_instance_metadata_many(
pub(crate) async fn list_instance_metadata(
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceMetadataRecord>> {
let rows = sqlx::query_as!(
InstanceMetadataRow,
r#"
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
COALESCE((
SELECT json_group_array(group_name)
FROM (
SELECT group_name
FROM instance_groups
WHERE instance_id = i.id
ORDER BY group_name
)
), '[]') AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
FROM instances i
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
"#,
)
.fetch_all(pool)
.await?;
let rows =
query_instance_metadata!("", "FROM instances i", "WHERE 1 = ?", 1_i64,)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(InstanceMetadataRow::into_record)
@@ -653,59 +666,10 @@ pub(crate) async fn get_instance_launch_context(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceLaunchContext>> {
let row = sqlx::query_as!(
InstanceMetadataRow,
r#"
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
'[]' AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
FROM instances i
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
WHERE i.id = ?
"#,
let row = query_instance_metadata!(
"",
"FROM instances i",
"WHERE i.id = ?",
instance_id,
)
.fetch_optional(pool)
@@ -753,6 +717,9 @@ where
json(hosting_instance_ids) AS "hosting_instance_ids?: String",
hosting_active_instance_id,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_linked_user_id,
imported_name,
imported_version_number,
imported_filename
@@ -949,7 +916,6 @@ pub(crate) async fn upsert_instance_link(
let hosting_instance_ids = columns.hosting_instance_ids.as_deref();
let hosting_active_instance_id =
columns.hosting_active_instance_id.as_deref();
let shared_instance_id = columns.shared_instance_id.as_deref();
let imported_name = columns.imported_name.as_deref();
let imported_version_number = columns.imported_version_number.as_deref();
let imported_filename = columns.imported_filename.as_deref();
@@ -967,12 +933,11 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id,
hosting_instance_ids,
hosting_active_instance_id,
shared_instance_id,
imported_name,
imported_version_number,
imported_filename
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)
ON CONFLICT (instance_id) DO UPDATE SET
link_kind = excluded.link_kind,
modrinth_project_id = excluded.modrinth_project_id,
@@ -983,7 +948,6 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id = excluded.hosting_server_id,
hosting_instance_ids = excluded.hosting_instance_ids,
hosting_active_instance_id = excluded.hosting_active_instance_id,
shared_instance_id = excluded.shared_instance_id,
imported_name = excluded.imported_name,
imported_version_number = excluded.imported_version_number,
imported_filename = excluded.imported_filename
@@ -998,7 +962,6 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id,
hosting_instance_ids,
hosting_active_instance_id,
shared_instance_id,
imported_name,
imported_version_number,
imported_filename,
@@ -1009,6 +972,64 @@ pub(crate) async fn upsert_instance_link(
Ok(())
}
pub(crate) async fn set_shared_instance_attachment(
instance_id: &str,
attachment: Option<&SharedInstanceAttachment>,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let shared_instance_id = attachment.map(|value| value.id.to_string());
let shared_instance_role =
attachment.map(|value| value.role.as_str().to_string());
let shared_instance_manager_id =
attachment.and_then(|value| value.manager_id.as_deref());
let shared_instance_linked_user_id =
attachment.and_then(|value| value.linked_user_id.as_deref());
let shared_instance_server_manager_name =
attachment.and_then(|value| value.server_manager_name.as_deref());
let shared_instance_server_manager_icon_url =
attachment.and_then(|value| value.server_manager_icon_url.as_deref());
sqlx::query!(
"
INSERT INTO instance_links (
instance_id,
link_kind,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_server_manager_name,
shared_instance_server_manager_icon_url,
shared_instance_linked_user_id
)
VALUES (?, 'unmanaged', ?, ?, ?, ?, ?, ?)
ON CONFLICT (instance_id) DO UPDATE SET
link_kind = CASE
WHEN excluded.shared_instance_id IS NULL
AND instance_links.link_kind = 'shared_instance'
THEN 'unmanaged'
ELSE instance_links.link_kind
END,
shared_instance_id = excluded.shared_instance_id,
shared_instance_role = excluded.shared_instance_role,
shared_instance_manager_id = excluded.shared_instance_manager_id,
shared_instance_server_manager_name = excluded.shared_instance_server_manager_name,
shared_instance_server_manager_icon_url = excluded.shared_instance_server_manager_icon_url,
shared_instance_linked_user_id = excluded.shared_instance_linked_user_id
",
instance_id,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_server_manager_name,
shared_instance_server_manager_icon_url,
shared_instance_linked_user_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn replace_instance_groups(
instance_id: &str,
groups: &[String],
@@ -1094,7 +1115,6 @@ struct InstanceLinkColumns {
hosting_server_id: Option<String>,
hosting_instance_ids: Option<String>,
hosting_active_instance_id: Option<String>,
shared_instance_id: Option<String>,
imported_name: Option<String>,
imported_version_number: Option<String>,
imported_filename: Option<String>,
@@ -1114,7 +1134,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1132,7 +1151,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1147,7 +1165,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1166,7 +1183,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1186,7 +1202,6 @@ fn instance_link_columns(
hosting_instance_ids: Some(serde_json::to_string(instance_ids)?),
hosting_active_instance_id: active_instance_id
.map(|value| value.to_string()),
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1207,28 +1222,27 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: name.clone(),
imported_version_number: version_number.clone(),
imported_filename: filename.clone(),
}),
InstanceLink::SharedInstance { shared_instance_id } => {
Ok(InstanceLinkColumns {
link_kind: "shared_instance",
modrinth_project_id: None,
modrinth_version_id: None,
server_project_id: None,
content_project_id: None,
content_version_id: None,
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: Some(shared_instance_id.to_string()),
imported_name: None,
imported_version_number: None,
imported_filename: None,
})
}
InstanceLink::SharedInstance {
modpack_project_id,
modpack_version_id,
} => Ok(InstanceLinkColumns {
link_kind: "shared_instance",
modrinth_project_id: modpack_project_id.clone(),
modrinth_version_id: modpack_version_id.clone(),
server_project_id: None,
content_project_id: None,
content_version_id: None,
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
}),
}
}
@@ -1273,6 +1287,60 @@ fn launch_overrides_from_json(
}
}
fn shared_instance_attachment(
shared_instance_id: Option<String>,
shared_instance_role: Option<String>,
shared_instance_manager_id: Option<String>,
shared_instance_server_manager_name: Option<String>,
shared_instance_server_manager_icon_url: Option<String>,
shared_instance_linked_user_id: Option<String>,
shared_sync_status: Option<String>,
applied_update_id: Option<String>,
latest_available_update_id: Option<String>,
) -> crate::Result<Option<SharedInstanceAttachment>> {
let Some(id) = shared_instance_id else {
return Ok(None);
};
let role = match shared_instance_role {
Some(role) => SharedInstanceRole::from_stored_str(&role)?,
None => SharedInstanceRole::Member,
};
let status = match shared_sync_status {
Some(status) => ContentSetSyncStatus::from_str(&status)?,
None => ContentSetSyncStatus::Unknown,
};
Ok(Some(SharedInstanceAttachment {
id,
role,
manager_id: shared_instance_manager_id,
server_manager_name: shared_instance_server_manager_name,
server_manager_icon_url: shared_instance_server_manager_icon_url,
linked_user_id: shared_instance_linked_user_id,
status,
applied_version: optional_i32(applied_update_id, "applied_update_id")?,
latest_version: optional_i32(
latest_available_update_id,
"latest_available_update_id",
)?,
}))
}
fn optional_i32(
value: Option<String>,
column: &str,
) -> crate::Result<Option<i32>> {
value
.map(|value| {
value.parse().map_err(|err| {
crate::ErrorKind::InputError(format!("Invalid {column}: {err}"))
.into()
})
})
.transpose()
}
fn parse_uuid(value: Option<String>, column: &str) -> crate::Result<Uuid> {
let value = required(value, column)?;
@@ -502,6 +502,13 @@ pub(crate) async fn add_project_bytes(
version_id: Option<&str>,
state: &State,
) -> crate::Result<String> {
if !path_util::is_safe_file_name(file_name) {
return Err(crate::ErrorKind::InputError(format!(
"Project file {file_name} has an invalid file name"
))
.into());
}
let _content_lock = state.lock_instance_content(instance_id).await;
let scope = resolve_content_scope(instance_id, None, state).await?;
let project_type = match project_type {
@@ -561,6 +568,7 @@ pub(crate) async fn add_project_bytes(
)
.await?;
tx.commit().await?;
super::mark_shared_instance_stale(instance_id, &state.pool).await?;
Ok(relative_path)
}
@@ -608,6 +616,7 @@ pub(crate) async fn record_project_file(
)
.await?;
tx.commit().await?;
super::mark_shared_instance_stale(instance_id, &state.pool).await?;
Ok(())
}
@@ -711,6 +720,8 @@ pub(crate) async fn toggle_disable_project(
}
tx.commit().await?;
super::mark_shared_instance_stale(instance_id, &state.pool).await?;
Ok(new_path)
}
@@ -752,9 +763,36 @@ pub(crate) async fn remove_project(
tx.commit().await?;
}
super::mark_shared_instance_stale(instance_id, &state.pool).await?;
Ok(())
}
pub(crate) async fn content_source_kind_for_project_path(
instance_id: &str,
project_path: &str,
state: &State,
) -> crate::Result<Option<ContentSourceKind>> {
let scope = resolve_content_scope(instance_id, None, state).await?;
let Some(file) = content_rows::get_instance_file_by_relative_path(
&scope.instance.id,
project_path,
&state.pool,
)
.await?
else {
return Ok(None);
};
let entries =
content_rows::get_content_entries(&scope.content_set_id, &state.pool)
.await?;
Ok(entries.into_iter().find_map(|entry| {
(entry.file_id.as_deref() == Some(file.id.as_str()))
.then_some(entry.source_kind)
}))
}
pub(crate) async fn rename_project_companion_file(
instance_id: &str,
old_project_path: &str,
@@ -52,6 +52,7 @@ struct InstalledProject {
relative_path: String,
project_id: Option<String>,
version_id: Option<String>,
source_kind: ContentSourceKind,
enabled: bool,
}
@@ -295,8 +296,14 @@ async fn plan_bulk_update(
instance_id: &str,
state: &State,
) -> crate::Result<BulkUpdatePlan> {
let updateable_paths =
bulk_updateable_project_paths(instance_id, state).await?;
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(),
@@ -330,6 +337,30 @@ async fn plan_bulk_update(
})?;
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| {
@@ -416,6 +447,7 @@ async fn plan_bulk_update(
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(
@@ -426,7 +458,16 @@ async fn bulk_updateable_project_paths(
)
.await?;
Ok(items.into_iter().map(|item| item.file_path).collect())
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(
@@ -471,10 +512,27 @@ fn installed_project_from_row(
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,
@@ -102,11 +102,28 @@ pub(crate) async fn edit_instance(
patch: EditInstance,
pool: &SqlitePool,
) -> crate::Result<Instance> {
let modifies_content =
patch.link.is_some() || patch.content_set_patch.is_some();
let should_mark_shared_instance_stale = patch.link.is_some()
|| patch.content_set_patch.as_ref().is_some_and(|patch| {
patch.source_kind.is_some()
|| patch.game_version.is_some()
|| patch.loader.is_some()
|| patch.loader_version.is_some()
});
let mut instance = instance_rows::get_instance_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
if modifies_content
&& instance_rows::is_instance_quarantined(instance_id, pool).await?
{
return Err(crate::ErrorKind::InputError(
"Content in quarantined instances cannot be changed.".to_string(),
)
.into());
}
let now = Utc::now();
apply_instance_patch(&mut instance, &patch, now);
@@ -169,6 +186,10 @@ pub(crate) async fn edit_instance(
tx.commit().await?;
if should_mark_shared_instance_stale {
super::mark_shared_instance_stale(instance_id, pool).await?;
}
Ok(instance)
}
@@ -1,6 +1,6 @@
use crate::state::instances::{
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
adapters::sqlite::instance_rows,
SharedInstanceAttachment, adapters::sqlite::instance_rows,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
@@ -10,6 +10,9 @@ pub struct InstanceMetadata {
pub instance: Instance,
pub applied_content_set: ContentSet,
pub link: InstanceLink,
pub shared_instance: Option<SharedInstanceAttachment>,
#[serde(default)]
pub quarantined: bool,
pub groups: Vec<String>,
pub launch_overrides: InstanceLaunchOverrides,
}
@@ -25,44 +28,62 @@ pub(crate) async fn get_instance_metadata(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceMetadata>> {
Ok(
instance_rows::get_instance_metadata_by_id(instance_id, pool)
.await?
.map(Into::into),
)
let Some(record) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(None);
};
let quarantined =
instance_rows::is_instance_quarantined(instance_id, pool).await?;
Ok(Some(instance_metadata(record, quarantined)))
}
pub(crate) async fn get_instances_metadata(
instance_ids: &[&str],
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceMetadata>> {
Ok(
instance_rows::get_instance_metadata_many(instance_ids, pool)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
let records =
instance_rows::get_instance_metadata_many(instance_ids, pool).await?;
let quarantined_ids =
instance_rows::get_quarantined_instance_ids(pool).await?;
Ok(records
.into_iter()
.map(|record| {
let quarantined = quarantined_ids.contains(&record.instance.id);
instance_metadata(record, quarantined)
})
.collect())
}
pub(crate) async fn list_instances(
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceMetadata>> {
Ok(instance_rows::list_instance_metadata(pool)
.await?
let records = instance_rows::list_instance_metadata(pool).await?;
let quarantined_ids =
instance_rows::get_quarantined_instance_ids(pool).await?;
Ok(records
.into_iter()
.map(Into::into)
.map(|record| {
let quarantined = quarantined_ids.contains(&record.instance.id);
instance_metadata(record, quarantined)
})
.collect())
}
impl From<instance_rows::InstanceMetadataRecord> for InstanceMetadata {
fn from(record: instance_rows::InstanceMetadataRecord) -> Self {
Self {
instance: record.instance,
applied_content_set: record.applied_content_set,
link: record.link,
groups: record.groups,
launch_overrides: record.launch_overrides,
}
fn instance_metadata(
record: instance_rows::InstanceMetadataRecord,
quarantined: bool,
) -> InstanceMetadata {
InstanceMetadata {
instance: record.instance,
applied_content_set: record.applied_content_set,
link: record.link,
shared_instance: record.shared_instance,
quarantined,
groups: record.groups,
launch_overrides: record.launch_overrides,
}
}
@@ -528,6 +528,7 @@ pub(crate) async fn dependencies_to_content_items(
has_update: false,
update_version_id: None,
date_added: None,
source_kind: None,
})
})
.collect::<Vec<_>>();
@@ -738,6 +739,7 @@ async fn content_projects_for_scope(
size: file.size,
metadata: file_metadata_from_entry_or_cache(entry, metadata),
project_type,
source_kind: entry.map(|entry| entry.source_kind),
},
);
}
@@ -897,9 +899,13 @@ async fn content_files_to_content_items(
date_published: Some(version.date_published.to_rfc3339()),
}),
owner,
has_update: file.update_version_id.is_some(),
has_update: file.update_version_id.is_some()
&& !file.source_kind.is_some_and(
ContentSourceKind::is_shared_instance_managed,
),
update_version_id: file.update_version_id.clone(),
date_added: modification_times[index].clone(),
source_kind: file.source_kind,
}
})
.collect::<Vec<_>>();
@@ -1089,6 +1095,10 @@ fn linked_modpack_ids(link: &InstanceLink) -> Option<(String, String)> {
version_id: Some(version_id),
..
} => Some((project_id.clone(), version_id.clone())),
InstanceLink::SharedInstance {
modpack_project_id: Some(project_id),
modpack_version_id: Some(version_id),
} => Some((project_id.clone(), version_id.clone())),
_ => None,
}
}
@@ -1103,6 +1113,10 @@ fn linked_modpack_source_kind(
InstanceLink::ServerProjectModpack { .. } => {
Some(ContentSourceKind::ServerProject)
}
InstanceLink::SharedInstance {
modpack_project_id: Some(_),
modpack_version_id: Some(_),
} => Some(ContentSourceKind::ModrinthModpack),
_ => None,
}
}
@@ -1348,12 +1362,7 @@ async fn get_modpack_identifiers(
}
fn project_type_from_api_name(project_type: &str) -> ProjectType {
match project_type {
"resourcepack" => ProjectType::ResourcePack,
"shader" => ProjectType::ShaderPack,
"datapack" => ProjectType::DataPack,
_ => ProjectType::Mod,
}
ProjectType::from_name(project_type).unwrap_or(ProjectType::Mod)
}
fn sort_content_items(items: &mut [ContentItem]) {
@@ -41,3 +41,9 @@ mod check_content_updates;
mod apply_content_update;
pub(crate) use self::apply_content_update::*;
mod shared_instance;
pub(crate) use self::shared_instance::{
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
quarantine_shared_instance, set_shared_instance_sync_status,
};
@@ -0,0 +1,184 @@
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
use crate::state::instances::{
ContentSetRemoteRef, ContentSetRemoteRefType, ContentSetSyncProvider,
ContentSetSyncState, ContentSetSyncStatus, InstanceLink,
SharedInstanceAttachment, SharedInstanceAttachmentInput,
SharedInstanceRole,
};
use chrono::Utc;
use sqlx::SqlitePool;
pub(crate) async fn attach_shared_instance(
instance_id: &str,
input: SharedInstanceAttachmentInput,
pool: &SqlitePool,
) -> crate::Result<()> {
let metadata =
instance_rows::get_instance_metadata_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let content_set_id = metadata.applied_content_set.id;
let attachment = SharedInstanceAttachment::from(input);
let sync_state =
shared_sync_state(&content_set_id, &attachment, Some(Utc::now()));
let mut tx = pool.begin().await?;
instance_rows::set_shared_instance_attachment(
instance_id,
Some(&attachment),
&mut tx,
)
.await?;
content_rows::upsert_content_set_remote_ref(
&ContentSetRemoteRef {
content_set_id: content_set_id.clone(),
ref_type: ContentSetRemoteRefType::SharedContentSet,
ref_id: attachment.id.clone(),
},
&mut tx,
)
.await?;
content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn clear_shared_instance(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
detach_shared_instance(instance_id, false, pool).await
}
pub(crate) async fn quarantine_shared_instance(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
detach_shared_instance(instance_id, true, pool).await
}
async fn detach_shared_instance(
instance_id: &str,
quarantine: bool,
pool: &SqlitePool,
) -> crate::Result<()> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(());
};
let content_set_id = metadata.applied_content_set.id;
let retained_modpack_link = match metadata.link {
InstanceLink::SharedInstance {
modpack_project_id: Some(project_id),
modpack_version_id: Some(version_id),
} => Some(InstanceLink::ModrinthModpack {
project_id,
version_id,
}),
_ => None,
};
let mut tx = pool.begin().await?;
instance_rows::set_shared_instance_attachment(instance_id, None, &mut tx)
.await?;
if quarantine {
instance_rows::set_instance_quarantined(instance_id, true, &mut tx)
.await?;
}
if let Some(link) = retained_modpack_link.as_ref() {
instance_rows::upsert_instance_link(instance_id, link, &mut tx).await?;
}
content_rows::delete_content_set_remote_ref(
&content_set_id,
ContentSetRemoteRefType::SharedContentSet,
&mut tx,
)
.await?;
content_rows::delete_content_set_sync_state(&content_set_id, &mut tx)
.await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn set_shared_instance_sync_status(
instance_id: &str,
status: ContentSetSyncStatus,
applied_version: Option<i32>,
latest_version: Option<i32>,
pool: &SqlitePool,
) -> crate::Result<()> {
let metadata =
instance_rows::get_instance_metadata_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let Some(mut attachment) = metadata.shared_instance else {
return Ok(());
};
attachment.status = status;
attachment.applied_version = applied_version;
attachment.latest_version = latest_version;
let sync_state = shared_sync_state(
&metadata.applied_content_set.id,
&attachment,
Some(Utc::now()),
);
let mut tx = pool.begin().await?;
content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn mark_shared_instance_stale(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(());
};
let Some(attachment) = metadata.shared_instance else {
return Ok(());
};
if attachment.role != SharedInstanceRole::Owner {
return Ok(());
}
set_shared_instance_sync_status(
instance_id,
ContentSetSyncStatus::Stale,
attachment.applied_version,
attachment.latest_version,
pool,
)
.await
}
fn shared_sync_state(
content_set_id: &str,
attachment: &SharedInstanceAttachment,
checked_at: Option<chrono::DateTime<Utc>>,
) -> ContentSetSyncState {
ContentSetSyncState {
content_set_id: content_set_id.to_string(),
provider: ContentSetSyncProvider::SharedInstance,
applied_update_id: attachment
.applied_version
.map(|value| value.to_string()),
latest_available_update_id: attachment
.latest_version
.map(|value| value.to_string()),
checked_at,
status: attachment.status,
}
}
@@ -75,6 +75,7 @@ pub(crate) async fn sync_instance_content_files(
let now = Utc::now();
let mut files = Vec::new();
let mut present_without_hash_ids = Vec::new();
let mut restored_without_hash = false;
for file in scanned {
let hash_key = file.hash_cache_key.trim_end_matches(".disabled");
@@ -82,6 +83,7 @@ pub(crate) async fn sync_instance_content_files(
let Some(hash) = hashes_by_key.get(hash_key) else {
if let Some(existing_file) = existing_file {
present_without_hash_ids.push(existing_file.id.clone());
restored_without_hash |= existing_file.missing;
}
continue;
};
@@ -102,6 +104,19 @@ pub(crate) async fn sync_instance_content_files(
});
}
let content_changed = !missing_file_ids.is_empty()
|| restored_without_hash
|| files.iter().any(|file| {
existing_files_by_path.get(&file.relative_path).is_none_or(
|existing| {
existing.missing
|| existing.enabled != file.enabled
|| existing.sha1 != file.sha1
|| existing.size != file.size
},
)
});
let mut tx = state.pool.begin().await?;
for file_id in missing_file_ids {
sqlite::content_rows::set_instance_file_missing(
@@ -129,6 +144,10 @@ pub(crate) async fn sync_instance_content_files(
tx.commit().await?;
if content_changed {
super::mark_shared_instance_stale(&instance.id, &state.pool).await?;
}
Ok(stored_files)
}
@@ -1,3 +1,4 @@
use super::ContentSourceKind;
use crate::state::{Project, ProjectType, Version};
use serde::{Deserialize, Serialize};
@@ -15,6 +16,7 @@ pub struct ContentItem {
pub has_update: bool,
pub update_version_id: Option<String>,
pub date_added: Option<String>,
pub source_kind: Option<ContentSourceKind>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -10,6 +10,10 @@ pub use self::commands::{
AppliedContentSetPatch, CreateInstance, EditInstance,
InstanceLaunchOverridesPatch, InstanceMetadata,
};
pub(crate) use self::commands::{
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
quarantine_shared_instance, set_shared_instance_sync_status,
};
pub(crate) use self::commands::{
create_instance, edit_instance, get_instance, get_instances_metadata,
list_instances, refresh_all_instances, remove_instance,
@@ -16,6 +16,15 @@ pub enum ContentSourceKind {
}
impl ContentSourceKind {
pub fn is_shared_instance_managed(self) -> bool {
matches!(
self,
Self::SharedInstance
| Self::ModrinthModpack
| Self::ImportedModpack
)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::ContentSetSyncStatus;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstanceLink {
@@ -32,7 +34,85 @@ pub enum InstanceLink {
version_number: Option<String>,
filename: Option<String>,
},
/// Modpack provenance installed by a shared instance. Remote membership,
/// manager identity, and synchronization state belong to
/// [`SharedInstanceAttachment`].
SharedInstance {
shared_instance_id: Uuid,
modpack_project_id: Option<String>,
modpack_version_id: Option<String>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SharedInstanceRole {
Owner,
Member,
}
impl SharedInstanceRole {
pub fn is_member(self) -> bool {
self == Self::Member
}
pub fn as_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Member => "member",
}
}
pub fn from_stored_str(value: &str) -> crate::Result<Self> {
match value {
"owner" => Ok(Self::Owner),
"member" => Ok(Self::Member),
other => Err(super::unknown_value("shared instance role", other)),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
/// The remote shared-instance relationship for a local instance. This is
/// independent from the optional modpack provenance stored in [`InstanceLink`].
pub struct SharedInstanceAttachment {
pub id: String,
pub role: SharedInstanceRole,
pub manager_id: Option<String>,
#[serde(default)]
pub server_manager_name: Option<String>,
#[serde(default)]
pub server_manager_icon_url: Option<String>,
pub linked_user_id: Option<String>,
pub status: ContentSetSyncStatus,
pub applied_version: Option<i32>,
pub latest_version: Option<i32>,
}
#[derive(Clone, Debug)]
pub(crate) struct SharedInstanceAttachmentInput {
pub id: String,
pub role: SharedInstanceRole,
pub manager_id: Option<String>,
pub server_manager_name: Option<String>,
pub server_manager_icon_url: Option<String>,
pub linked_user_id: Option<String>,
pub status: ContentSetSyncStatus,
pub applied_version: Option<i32>,
pub latest_version: Option<i32>,
}
impl From<SharedInstanceAttachmentInput> for SharedInstanceAttachment {
fn from(input: SharedInstanceAttachmentInput) -> Self {
Self {
id: input.id,
role: input.role,
manager_id: input.manager_id,
server_manager_name: input.server_manager_name,
server_manager_icon_url: input.server_manager_icon_url,
linked_user_id: input.linked_user_id,
status: input.status,
applied_version: input.applied_version,
latest_version: input.latest_version,
}
}
}
@@ -1,4 +1,5 @@
use crate::State;
use crate::api::instance::CONFIG_DIRECTORY;
use crate::event::InstancePayloadType;
use crate::event::emit::{emit_instance, emit_warning};
use crate::state::{
@@ -128,17 +129,41 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
Some(InstancePayloadType::WorldUpdated {
world,
})
} else if first_file_name
.as_ref()
.is_none_or(|x| *x != "saves")
{
} else if first_file_name.as_ref().is_none_or(
|x| *x != "saves" && *x != CONFIG_DIRECTORY,
) {
Some(InstancePayloadType::Synced)
} else {
None
};
if let Some(event) = event {
let emit_instance_id = instance_id.clone();
let mark_shared_stale = first_file_name
.as_ref()
.is_some_and(|name| {
ProjectType::iterator().any(
|project_type| {
*name
== project_type
.get_folder()
},
)
});
tokio::spawn(async move {
if mark_shared_stale
&& let Ok(state) =
State::get().await
&& let Err(error) =
crate::state::mark_shared_instance_stale(
&emit_instance_id,
&state.pool,
)
.await
{
tracing::error!(
"Failed to mark shared instance stale after filesystem sync: {error}"
);
}
let _ = emit_instance(
&emit_instance_id,
event,
@@ -194,10 +219,11 @@ pub(crate) async fn watch_instance_folder(
}
let mut to_watch = Vec::new();
for sub_path in ProjectType::iterator()
.map(|x| x.get_folder())
.chain(["crash-reports", "saves"])
{
for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([
"crash-reports",
"saves",
CONFIG_DIRECTORY,
]) {
let full_path = full_instance_path.join(sub_path);
let meta = tokio::fs::symlink_metadata(&full_path).await;
+4
View File
@@ -195,6 +195,10 @@ pub const fn get_login_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-in")
}
pub const fn get_signup_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-up")
}
pub async fn finish_login_flow(
code: &str,
semaphore: &FetchSemaphore,
+11 -6
View File
@@ -915,13 +915,10 @@ pub async fn write_cached_icon(
bytes: Bytes,
semaphore: &IoSemaphore,
) -> crate::Result<PathBuf> {
let extension = Path::new(&icon_path).extension().and_then(OsStr::to_str);
let hash = sha1_async(bytes.clone()).await?;
let path = cache_dir.join("icons").join(if let Some(ext) = extension {
format!("{hash}.{ext}")
} else {
hash
});
let path = cache_dir
.join("icons")
.join(cached_icon_file_name(icon_path, &hash));
write(&path, &bytes, semaphore).await?;
@@ -929,6 +926,14 @@ pub async fn write_cached_icon(
Ok(path)
}
fn cached_icon_file_name(icon_path: &str, hash: &str) -> String {
let path = icon_path.split(['?', '#']).next().unwrap_or(icon_path);
match Path::new(path).extension().and_then(OsStr::to_str) {
Some(extension) => format!("{hash}.{extension}"),
None => hash.to_string(),
}
}
pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
let hash = tokio::task::spawn_blocking(move || {
sha1_smol::Sha1::from(bytes).hexdigest()