mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(¤t.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(¤t.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()
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
¤t_version_ids,
|
||||
¤t_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,
|
||||
¤t_version_ids,
|
||||
¤t_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(¤t, &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
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user