feat: instance sharing thru shared-instances service (#6569)

* feat: implement instance share page + search_users backend call

* feat: invite players modal

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

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

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

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

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

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

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

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

* refactor: centralized managed content

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

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

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

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+284 -8
View File
@@ -7,7 +7,257 @@ use super::model::{
use super::store;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::State;
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
use crate::state::{
ContentEntry, ContentSetRemoteRef, ContentSetRemoteRefType,
ContentSetSyncProvider, ContentSetSyncState, InstanceFile,
InstanceMetadata, State,
};
use async_walkdir::WalkDir;
use chrono::Utc;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;
const SHARED_INSTANCE_ROLLBACK_FILE: &str = "rollback.json";
const SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR: &str = "instance";
#[derive(Deserialize, Serialize)]
struct SharedInstanceUpdateRollback {
files: Vec<InstanceFile>,
entries: Vec<ContentEntry>,
}
pub(super) async fn prepare_shared_instance_update_backup(
job_id: Uuid,
metadata: &InstanceMetadata,
state: &State,
) -> crate::Result<PathBuf> {
let staging_dir = state
.directories
.metadata_dir()
.join("install_job_backups")
.join(job_id.to_string());
if tokio::fs::try_exists(&staging_dir).await? {
crate::util::io::remove_dir_all(&staging_dir).await?;
}
crate::util::io::create_dir_all(&staging_dir).await?;
let result = async {
let files = content_rows::get_instance_files(
&metadata.instance.id,
&state.pool,
)
.await?;
let entries = content_rows::get_content_entries(
&metadata.applied_content_set.id,
&state.pool,
)
.await?;
let snapshot = SharedInstanceUpdateRollback { files, entries };
let instance_path = state
.directories
.instances_dir()
.join(&metadata.instance.path);
copy_directory(
&instance_path,
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
state,
)
.await?;
crate::util::io::write(
staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE),
serde_json::to_vec(&snapshot)?,
)
.await?;
Ok::<(), crate::Error>(())
}
.await;
if result.is_err() {
let _ = crate::util::io::remove_dir_all(&staging_dir).await;
}
result?;
Ok(staging_dir)
}
pub(super) async fn clear_staging_dir(job_state: &InstallJobState) {
let Some(staging_dir) = &job_state.paths.staging_dir else {
return;
};
if let Err(error) = crate::util::io::remove_dir_all(staging_dir).await
&& error.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
path = %staging_dir.display(),
"Failed to remove install rollback backup: {error}"
);
}
}
async fn restore_shared_instance_update(
staging_dir: &Path,
rollback: &super::model::InstallRollbackState,
state: &State,
) -> crate::Result<()> {
let snapshot = serde_json::from_slice::<SharedInstanceUpdateRollback>(
&crate::util::io::read(staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE))
.await?,
)?;
let instance_path = state
.directories
.instances_dir()
.join(&rollback.instance.instance.path);
if tokio::fs::try_exists(&instance_path).await? {
crate::util::io::remove_dir_all(&instance_path).await?;
}
copy_directory(
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
&instance_path,
state,
)
.await?;
content_rows::restore_instance_content_snapshot(
&rollback.instance.instance.id,
&snapshot.files,
&snapshot.entries,
&state.pool,
)
.await?;
restore_instance_metadata(&rollback.instance, state).await?;
Ok(())
}
async fn restore_instance_metadata(
metadata: &InstanceMetadata,
state: &State,
) -> crate::Result<()> {
let content_set_id = metadata.applied_content_set.id.as_str();
let mut tx = state.pool.begin().await?;
instance_rows::update_instance(&metadata.instance, &mut tx).await?;
content_rows::update_content_set(&metadata.applied_content_set, &mut tx)
.await?;
instance_rows::upsert_instance_link(
&metadata.instance.id,
&metadata.link,
&mut tx,
)
.await?;
instance_rows::set_shared_instance_attachment(
&metadata.instance.id,
metadata.shared_instance.as_ref(),
&mut tx,
)
.await?;
instance_rows::replace_instance_groups(
&metadata.instance.id,
&metadata.groups,
&mut tx,
)
.await?;
instance_rows::upsert_instance_launch_overrides(
&metadata.launch_overrides,
&mut tx,
)
.await?;
content_rows::delete_content_set_remote_ref(
content_set_id,
ContentSetRemoteRefType::SharedContentSet,
&mut tx,
)
.await?;
content_rows::delete_content_set_sync_state(content_set_id, &mut tx)
.await?;
if let Some(attachment) = &metadata.shared_instance {
content_rows::upsert_content_set_remote_ref(
&ContentSetRemoteRef {
content_set_id: content_set_id.to_string(),
ref_type: ContentSetRemoteRefType::SharedContentSet,
ref_id: attachment.id.clone(),
},
&mut tx,
)
.await?;
content_rows::upsert_content_set_sync_state(
&ContentSetSyncState {
content_set_id: content_set_id.to_string(),
provider: ContentSetSyncProvider::SharedInstance,
applied_update_id: attachment
.applied_version
.map(|value| value.to_string()),
latest_available_update_id: attachment
.latest_version
.map(|value| value.to_string()),
checked_at: Some(Utc::now()),
status: attachment.status,
},
&mut tx,
)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn copy_directory(
source: &Path,
target: &Path,
state: &State,
) -> crate::Result<()> {
crate::util::io::create_dir_all(target).await?;
let mut walker = WalkDir::new(source);
while let Some(entry) = walker.next().await {
let entry = entry.map_err(|error| {
crate::ErrorKind::FSError(format!(
"Failed to read instance backup path: {error}"
))
})?;
let entry_path = entry.path();
let relative_path = entry_path.strip_prefix(source)?;
let target_path = target.join(relative_path);
let file_type = entry.file_type().await?;
if file_type.is_dir() {
crate::util::io::create_dir_all(&target_path).await?;
} else if file_type.is_file() {
crate::util::fetch::copy(
&entry_path,
&target_path,
&state.io_semaphore,
)
.await?;
} else if file_type.is_symlink() {
copy_symlink(&entry_path, &target_path).await?;
}
}
Ok(())
}
async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
if let Some(parent) = target.parent() {
crate::util::io::create_dir_all(parent).await?;
}
let link_target = tokio::fs::read_link(source).await?;
#[cfg(unix)]
tokio::fs::symlink(link_target, target).await?;
#[cfg(windows)]
{
let metadata = tokio::fs::metadata(source).await?;
if metadata.is_dir() {
tokio::fs::symlink_dir(link_target, target).await?;
} else {
tokio::fs::symlink_file(link_target, target).await?;
}
}
Ok(())
}
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
let jobs = store::list_interrupted_candidates(state).await?;
@@ -61,6 +311,9 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
state,
)
.await?;
if job.state.rollback_error.is_none() {
clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
}
@@ -96,6 +349,15 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
..
} => None,
},
InstallRequest::CreateSharedInstance { data } => {
Some(InstallJobDisplay {
title: data.name.clone(),
icon: data
.modpack
.as_ref()
.and_then(|modpack| modpack.icon_url.clone()),
})
}
InstallRequest::ImportInstance {
instance_folder, ..
} => Some(InstallJobDisplay {
@@ -104,7 +366,8 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
}),
InstallRequest::DuplicateInstance { .. }
| InstallRequest::InstallExistingInstance { .. }
| InstallRequest::InstallPackToExistingInstance { .. } => {
| InstallRequest::InstallPackToExistingInstance { .. }
| InstallRequest::UpdateSharedInstance { .. } => {
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
title: rollback.instance.instance.name.clone(),
icon: rollback.instance.instance.icon_path.clone(),
@@ -128,12 +391,25 @@ pub async fn apply_cleanup(
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
if let Some(rollback) = &job_state.rollback {
crate::state::instances::commands::set_instance_install_stage(
instance_id,
rollback.install_stage,
&state.pool,
)
.await?;
if matches!(
&job_state.request,
InstallRequest::UpdateSharedInstance { .. }
) && let Some(staging_dir) = &job_state.paths.staging_dir
{
restore_shared_instance_update(
staging_dir,
rollback,
state,
)
.await?;
} else {
crate::state::instances::commands::set_instance_install_stage(
instance_id,
rollback.install_stage,
&state.pool,
)
.await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
}