Sync source to CNB / Sync Git ref (push) Has been cancelled
从 Axolotl 移植跨实例同步子系统,当前进度: - 第1轮:synced_options/files.rs + mod.rs 骨架,state 层补齐 SyncedOption/InstanceSyncedOptions/CanonicalValue/GameOptionKind 等 - 2a:game_options 的 api_types/options_file/catalog(10文件) - 2b:game_options 除 resource_pack_options/local_settings_editor 外的全部(settings_editor/write_shared_settings/fullscreen/launch_overrides/instance_support/read_instance_changes/pack_updates/source_selection + locales/5文件);State 补 synced_options_lock/game_locale_indexer/lock_synced_options - synced_servers:6 文件全部搬入 - 35+20 处 sqlx::query! 宏改写为非宏 query+bind 已知未完成:synced_servers::operations 依赖 synced_options::instance_option_supported(属 orchestration,第5轮),当前编译不通。
1038 lines
34 KiB
Rust
1038 lines
34 KiB
Rust
use super::super::synced_options::{
|
|
CheckpointStatus, checkpoint, detach_link, ensure_link, finish_checkpoint,
|
|
instance_game_dir, instance_is_running, instance_option_enabled,
|
|
instance_option_supported, sha1_bytes, sha1_file, sync_files_are_protected,
|
|
};
|
|
use crate::state::{InstanceMetadata, SyncedOption};
|
|
use crate::{ErrorKind, State};
|
|
use quartz_nbt::NbtCompound;
|
|
use uuid::Uuid;
|
|
|
|
use super::SERVERS_FILE;
|
|
use super::codec::{
|
|
read_servers, server_address, server_hidden, server_identity_address,
|
|
server_identity_name, servers_to_bytes, update_server_data, write_servers,
|
|
};
|
|
use super::modpack::{
|
|
is_modpack_link, pack_state_exists, pack_state_matches_link,
|
|
reconstruct_modpack_servers,
|
|
};
|
|
use super::storage::{
|
|
begin_server_checkpoint, canonical_exists, commit_server_state,
|
|
generated_path, load_local, load_projection_entries, read_canonical,
|
|
read_server_snapshot, server_revision, write_local,
|
|
};
|
|
use super::types::{
|
|
CanonicalServer, DesyncServerMode, LocalServer, ProjectionEntry,
|
|
ProjectionOwner, ServerRecord, ServerSource, SyncedServer,
|
|
};
|
|
use std::collections::HashSet;
|
|
|
|
fn normalized_server_address(data: &NbtCompound) -> (String, u16) {
|
|
let address = server_identity_address(data);
|
|
let (host, port) = crate::server_address::parse_server_address(&address)
|
|
.unwrap_or((&address, 25565));
|
|
(host.trim().to_string(), port)
|
|
}
|
|
|
|
async fn sync_candidates(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<Vec<NbtCompound>> {
|
|
let locals = load_local(&metadata.instance.id, state).await?;
|
|
let mut servers =
|
|
read_servers(&instance_game_dir(metadata, state)?.join(SERVERS_FILE))
|
|
.await?;
|
|
for local in locals {
|
|
let local_address = normalized_server_address(&local.data);
|
|
let index = servers
|
|
.iter()
|
|
.position(|data| data == &local.data)
|
|
.or_else(|| {
|
|
servers.iter().position(|data| {
|
|
!local_address.0.is_empty()
|
|
&& normalized_server_address(data) == local_address
|
|
})
|
|
});
|
|
if let Some(index) = index {
|
|
servers.remove(index);
|
|
}
|
|
}
|
|
Ok(servers)
|
|
}
|
|
|
|
pub(in crate::api::instance) async fn seed_servers(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
if is_modpack_link(&metadata.link)
|
|
&& (!pack_state_matches_link(metadata, state).await?
|
|
|| !pack_state_exists(&metadata.instance.id, state).await?)
|
|
&& let Err(error) = reconstruct_modpack_servers(metadata, state).await
|
|
{
|
|
tracing::warn!(
|
|
"Failed to reconstruct the server pack state for {}: {error}",
|
|
metadata.instance.id
|
|
);
|
|
}
|
|
if is_modpack_link(&metadata.link)
|
|
&& !pack_state_exists(&metadata.instance.id, state).await?
|
|
{
|
|
return Err(ErrorKind::InputError(
|
|
"This linked modpack cannot be used as the multiplayer base until its supplied server list has been reconstructed. Choose an unmanaged instance instead."
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let servers = sync_candidates(metadata, state).await?;
|
|
let canonical = servers
|
|
.into_iter()
|
|
.map(|data| CanonicalServer {
|
|
id: Uuid::new_v4().to_string(),
|
|
data,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
commit_server_state(Some(&canonical), None, state).await?;
|
|
regenerate_servers(state).await
|
|
}
|
|
|
|
pub(in crate::api::instance) async fn merge_servers_from_instance(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
if is_modpack_link(&metadata.link)
|
|
&& (!pack_state_matches_link(metadata, state).await?
|
|
|| !pack_state_exists(&metadata.instance.id, state).await?)
|
|
&& let Err(error) = reconstruct_modpack_servers(metadata, state).await
|
|
{
|
|
tracing::warn!(
|
|
"Failed to reconstruct the server pack state for {}: {error}",
|
|
metadata.instance.id
|
|
);
|
|
}
|
|
if is_modpack_link(&metadata.link)
|
|
&& !pack_state_exists(&metadata.instance.id, state).await?
|
|
{
|
|
return Err(ErrorKind::InputError(
|
|
"This linked modpack cannot join multiplayer syncing until its supplied server list has been reconstructed."
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let candidates = sync_candidates(metadata, state).await?;
|
|
|
|
let mut canonical = read_canonical(state).await?;
|
|
for data in candidates {
|
|
let address = server_identity_address(&data);
|
|
let exists = canonical.iter().any(|server| {
|
|
server.data == data
|
|
|| (!address.is_empty()
|
|
&& server_identity_address(&server.data) == address)
|
|
});
|
|
if !exists {
|
|
canonical.push(CanonicalServer {
|
|
id: Uuid::new_v4().to_string(),
|
|
data,
|
|
});
|
|
}
|
|
}
|
|
if commit_server_state(Some(&canonical), None, state).await? {
|
|
regenerate_servers(state).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(in crate::api::instance) async fn ensure_servers(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
if is_modpack_link(&metadata.link)
|
|
&& !pack_state_matches_link(metadata, state).await?
|
|
&& let Err(error) = reconstruct_modpack_servers(metadata, state).await
|
|
{
|
|
tracing::warn!(
|
|
"Failed to reconstruct the server pack state for {}; retaining its last known per-instance server metadata: {error}",
|
|
metadata.instance.id
|
|
);
|
|
}
|
|
if !canonical_exists(state).await? {
|
|
if !is_modpack_link(&metadata.link) {
|
|
let servers = sync_candidates(metadata, state).await?;
|
|
let canonical = servers
|
|
.into_iter()
|
|
.map(|data| CanonicalServer {
|
|
id: Uuid::new_v4().to_string(),
|
|
data,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
commit_server_state(Some(&canonical), None, state).await?;
|
|
} else {
|
|
commit_server_state(Some(&[]), None, state).await?;
|
|
}
|
|
}
|
|
compose_instance(metadata, state).await
|
|
}
|
|
|
|
pub(in crate::api::instance) async fn detach_servers(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let generated = generated_path(state, &metadata.instance.id);
|
|
let local = instance_game_dir(metadata, state)?.join(SERVERS_FILE);
|
|
detach_link(&generated, &local).await
|
|
}
|
|
|
|
pub(in crate::api::instance) async fn reconcile_servers(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
if !effective(metadata, state).await? {
|
|
return Ok(());
|
|
}
|
|
let local_path = instance_game_dir(metadata, state)?.join(SERVERS_FILE);
|
|
if !local_path.exists() {
|
|
return compose_instance(metadata, state).await;
|
|
}
|
|
let checkpoint = checkpoint(
|
|
&metadata.instance.id,
|
|
SyncedOption::MultiplayerServers,
|
|
"default",
|
|
state,
|
|
)
|
|
.await?;
|
|
if checkpoint
|
|
.as_ref()
|
|
.is_some_and(|value| value.status == CheckpointStatus::Pending)
|
|
{
|
|
return compose_instance(metadata, state).await;
|
|
}
|
|
let actual = sha1_file(&local_path).await?;
|
|
let revision = server_revision(state).await?;
|
|
if checkpoint
|
|
.as_ref()
|
|
.map(|value| value.expected_sha1.as_str())
|
|
== Some(actual.as_str())
|
|
{
|
|
if checkpoint
|
|
.as_ref()
|
|
.is_some_and(|value| value.source_revision == revision)
|
|
{
|
|
return Ok(());
|
|
}
|
|
return compose_instance(metadata, state).await;
|
|
}
|
|
|
|
let current = read_servers(&local_path).await?;
|
|
let projections =
|
|
load_projection_entries(&metadata.instance.id, state).await?;
|
|
let projection_matches = match_projection_entries(¤t, &projections);
|
|
let mut matched = HashSet::new();
|
|
let mut canonical = read_canonical(state).await?;
|
|
let mut locals = load_local(&metadata.instance.id, state).await?;
|
|
|
|
let mut canonical_order = Vec::new();
|
|
|
|
for ((position, data), projection) in
|
|
current.into_iter().enumerate().zip(projection_matches)
|
|
{
|
|
if let Some(projection) = projection {
|
|
matched.insert(projection.id.clone());
|
|
match projection.owner {
|
|
ProjectionOwner::Synced => {
|
|
canonical_order.push(projection.id.clone());
|
|
if let Some(server) = canonical
|
|
.iter_mut()
|
|
.find(|server| server.id == projection.id)
|
|
&& data != projection.data
|
|
{
|
|
server.data = data;
|
|
}
|
|
}
|
|
ProjectionOwner::Instance => {
|
|
if let Some(server) = locals
|
|
.iter_mut()
|
|
.find(|server| server.id == projection.id)
|
|
{
|
|
if data != projection.data {
|
|
server.data = data;
|
|
}
|
|
server.position = position as i64;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let id = Uuid::new_v4().to_string();
|
|
canonical_order.push(id.clone());
|
|
canonical.push(CanonicalServer { id, data });
|
|
}
|
|
}
|
|
|
|
canonical.retain(|server| {
|
|
!projections.iter().any(|projection| {
|
|
projection.owner == ProjectionOwner::Synced
|
|
&& projection.id == server.id
|
|
&& !matched.contains(&projection.id)
|
|
})
|
|
});
|
|
locals.retain(|server| {
|
|
!projections.iter().any(|projection| {
|
|
projection.owner == ProjectionOwner::Instance
|
|
&& projection.id == server.id
|
|
&& !matched.contains(&projection.id)
|
|
})
|
|
});
|
|
let by_id = canonical
|
|
.iter()
|
|
.cloned()
|
|
.map(|server| (server.id.clone(), server))
|
|
.collect::<std::collections::HashMap<_, _>>();
|
|
let visible = canonical_order.iter().cloned().collect::<HashSet<_>>();
|
|
let mut ordered = canonical_order
|
|
.into_iter()
|
|
.filter_map(|id| by_id.get(&id).cloned());
|
|
for server in &mut canonical {
|
|
if visible.contains(&server.id)
|
|
&& let Some(next) = ordered.next()
|
|
{
|
|
*server = next;
|
|
}
|
|
}
|
|
let canonical_changed = commit_server_state(
|
|
Some(&canonical),
|
|
Some((&metadata.instance.id, &locals)),
|
|
state,
|
|
)
|
|
.await?;
|
|
if canonical_changed {
|
|
regenerate_servers(state).await
|
|
} else {
|
|
compose_instance(metadata, state).await
|
|
}
|
|
}
|
|
|
|
fn match_projection_entries<'a>(
|
|
current: &[NbtCompound],
|
|
projections: &'a [ProjectionEntry],
|
|
) -> Vec<Option<&'a ProjectionEntry>> {
|
|
let mut matches = vec![None; current.len()];
|
|
let mut matched_projections = HashSet::new();
|
|
|
|
for (position, data) in current.iter().enumerate() {
|
|
let candidate = projections
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(index, projection)| {
|
|
!matched_projections.contains(index) && projection.data == *data
|
|
})
|
|
.min_by_key(|(_, projection)| {
|
|
projection.position.abs_diff(position as i64)
|
|
})
|
|
.map(|(index, _)| index);
|
|
if let Some(index) = candidate {
|
|
matches[position] = Some(index);
|
|
matched_projections.insert(index);
|
|
}
|
|
}
|
|
|
|
for (position, data) in current.iter().enumerate() {
|
|
if matches[position].is_some() {
|
|
continue;
|
|
}
|
|
let address = server_identity_address(data);
|
|
if address.is_empty() {
|
|
continue;
|
|
}
|
|
let candidate = projections
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(index, projection)| {
|
|
!matched_projections.contains(index)
|
|
&& server_identity_address(&projection.data) == address
|
|
})
|
|
.min_by_key(|(_, projection)| {
|
|
projection.position.abs_diff(position as i64)
|
|
})
|
|
.map(|(index, _)| index);
|
|
if let Some(index) = candidate {
|
|
matches[position] = Some(index);
|
|
matched_projections.insert(index);
|
|
}
|
|
}
|
|
|
|
for (position, data) in current.iter().enumerate() {
|
|
if matches[position].is_some() {
|
|
continue;
|
|
}
|
|
let name = server_identity_name(data);
|
|
if name.is_empty() {
|
|
continue;
|
|
}
|
|
let candidate = projections
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(index, projection)| {
|
|
!matched_projections.contains(index)
|
|
&& server_identity_name(&projection.data) == name
|
|
})
|
|
.min_by_key(|(_, projection)| {
|
|
projection.position.abs_diff(position as i64)
|
|
})
|
|
.map(|(index, _)| index);
|
|
if let Some(index) = candidate {
|
|
matches[position] = Some(index);
|
|
matched_projections.insert(index);
|
|
}
|
|
}
|
|
|
|
matches
|
|
.into_iter()
|
|
.map(|projection| projection.map(|index| &projections[index]))
|
|
.collect()
|
|
}
|
|
|
|
#[tracing::instrument(name = "server_records", skip_all, fields(instance_id = %metadata.instance.id))]
|
|
pub(crate) async fn list_server_records(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<Vec<ServerRecord>> {
|
|
if participating(metadata, state).await? {
|
|
let (canonical, locals) =
|
|
read_server_snapshot(&metadata.instance.id, state).await?;
|
|
return Ok(merge_server_records(canonical, locals));
|
|
}
|
|
list_local_server_records(metadata, state).await
|
|
}
|
|
|
|
async fn list_server_records_locked(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<Vec<ServerRecord>> {
|
|
if participating(metadata, state).await? {
|
|
return compose_records(metadata, state).await;
|
|
}
|
|
list_local_server_records(metadata, state).await
|
|
}
|
|
|
|
async fn list_local_server_records(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<Vec<ServerRecord>> {
|
|
Ok(
|
|
read_servers(&instance_game_dir(metadata, state)?.join(SERVERS_FILE))
|
|
.await?
|
|
.into_iter()
|
|
.map(|data| ServerRecord {
|
|
id: Uuid::new_v4().to_string(),
|
|
source: ServerSource::LocalDesynced,
|
|
data,
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
/// Keeps an automatically added server local, even before server sync is enabled.
|
|
pub(crate) async fn ensure_managed_server(
|
|
metadata: &InstanceMetadata,
|
|
data: NbtCompound,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let _guard = state.lock_synced_options().await;
|
|
let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE);
|
|
let syncing = participating(metadata, state).await?;
|
|
let records = list_server_records_locked(metadata, state).await?;
|
|
let address = normalized_server_address(&data);
|
|
let name = server_identity_name(&data);
|
|
let existing_index = records
|
|
.iter()
|
|
.position(|record| {
|
|
normalized_server_address(&record.data) == address
|
|
&& server_identity_name(&record.data) == name
|
|
})
|
|
.or_else(|| {
|
|
records.iter().position(|record| {
|
|
normalized_server_address(&record.data) == address
|
|
})
|
|
});
|
|
let existing = existing_index.map(|index| &records[index]);
|
|
let position = existing_index.unwrap_or_else(|| {
|
|
records
|
|
.iter()
|
|
.position(ServerRecord::hidden)
|
|
.unwrap_or(records.len())
|
|
});
|
|
let data = existing
|
|
.map(|record| {
|
|
let mut existing_data = record.data.clone();
|
|
if record.hidden() {
|
|
existing_data.insert(
|
|
"name",
|
|
data.get::<_, &str>("name").unwrap_or_default().to_string(),
|
|
);
|
|
existing_data.insert("hidden", 0_i8);
|
|
}
|
|
existing_data
|
|
})
|
|
.unwrap_or(data);
|
|
let excluded_synced_server_id = existing
|
|
.filter(|record| record.source == ServerSource::UserSynced)
|
|
.map(|record| record.id.clone());
|
|
let mut locals = load_local(&metadata.instance.id, state).await?;
|
|
if let Some(local) = locals
|
|
.iter_mut()
|
|
.find(|server| normalized_server_address(&server.data) == address)
|
|
{
|
|
let excluded_synced_server_id = excluded_synced_server_id
|
|
.or_else(|| local.excluded_synced_server_id.clone());
|
|
if existing.is_some_and(|record| record.data == data)
|
|
&& local.data == data
|
|
&& local.position == position as i64
|
|
&& local.excluded_synced_server_id == excluded_synced_server_id
|
|
&& (!syncing || path.exists())
|
|
{
|
|
return Ok(());
|
|
}
|
|
local.data = data.clone();
|
|
local.position = position as i64;
|
|
local.excluded_synced_server_id = excluded_synced_server_id;
|
|
} else {
|
|
locals.push(LocalServer {
|
|
id: Uuid::new_v4().to_string(),
|
|
source: ServerSource::LinkedServerProject,
|
|
excluded_synced_server_id,
|
|
data: data.clone(),
|
|
position: position as i64,
|
|
});
|
|
}
|
|
write_local(&metadata.instance.id, &locals, state).await?;
|
|
if syncing {
|
|
if effective(metadata, state).await? {
|
|
compose_instance(metadata, state).await?;
|
|
}
|
|
} else if existing.is_none_or(|record| record.data != data) {
|
|
let mut servers = records
|
|
.into_iter()
|
|
.map(|record| record.data)
|
|
.collect::<Vec<_>>();
|
|
if let Some(index) = existing_index {
|
|
servers[index] = data;
|
|
} else {
|
|
servers.insert(position, data);
|
|
}
|
|
write_servers(&path, &servers).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn add_user_server(
|
|
metadata: &InstanceMetadata,
|
|
mut data: NbtCompound,
|
|
state: &State,
|
|
) -> crate::Result<String> {
|
|
let _guard = state.lock_synced_options().await;
|
|
let id = Uuid::new_v4().to_string();
|
|
if participating(metadata, state).await? {
|
|
let mut canonical = read_canonical(state).await?;
|
|
canonical.push(CanonicalServer {
|
|
id: id.clone(),
|
|
data,
|
|
});
|
|
commit_server_state(Some(&canonical), None, state).await?;
|
|
regenerate_servers(state).await?;
|
|
} else {
|
|
let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE);
|
|
let mut servers = read_servers(&path).await?;
|
|
let insert_index = servers
|
|
.iter()
|
|
.position(server_hidden)
|
|
.unwrap_or(servers.len());
|
|
servers.insert(insert_index, std::mem::take(&mut data));
|
|
write_servers(&path, &servers).await?;
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
async fn update_server_locked(
|
|
metadata: &InstanceMetadata,
|
|
server_id: &str,
|
|
data: NbtCompound,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
if participating(metadata, state).await? {
|
|
let records = compose_records(metadata, state).await?;
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.id == server_id)
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Unknown server".to_string())
|
|
})?;
|
|
if record.source == ServerSource::UserSynced {
|
|
let mut canonical = read_canonical(state).await?;
|
|
let server = canonical
|
|
.iter_mut()
|
|
.find(|server| server.id == server_id)
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Unknown server".to_string())
|
|
})?;
|
|
server.data = data;
|
|
commit_server_state(Some(&canonical), None, state).await?;
|
|
regenerate_servers(state).await?;
|
|
} else {
|
|
let mut locals = load_local(&metadata.instance.id, state).await?;
|
|
let server = locals
|
|
.iter_mut()
|
|
.find(|server| server.id == server_id)
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Unknown server".to_string())
|
|
})?;
|
|
server.data = data;
|
|
write_local(&metadata.instance.id, &locals, state).await?;
|
|
compose_instance(metadata, state).await?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
Err(ErrorKind::InputError(
|
|
"Stable server editing requires multiplayer syncing for this instance."
|
|
.to_string(),
|
|
)
|
|
.into())
|
|
}
|
|
|
|
pub(crate) async fn update_server_by_index(
|
|
metadata: &InstanceMetadata,
|
|
index: usize,
|
|
name: String,
|
|
address: String,
|
|
accept_textures: Option<bool>,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let _guard = state.lock_synced_options().await;
|
|
if participating(metadata, state).await? {
|
|
let records = list_server_records_locked(metadata, state).await?;
|
|
let record = records
|
|
.get(index)
|
|
.filter(|record| !record.hidden())
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"No editable server at index {index}"
|
|
))
|
|
})?;
|
|
let mut data = record.data.clone();
|
|
update_server_data(&mut data, name, address, accept_textures);
|
|
return update_server_locked(metadata, &record.id, data, state).await;
|
|
}
|
|
let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE);
|
|
let mut servers = read_servers(&path).await?;
|
|
let server = servers
|
|
.get_mut(index)
|
|
.filter(|server| !server_hidden(server))
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"No editable server at index {index}"
|
|
))
|
|
})?;
|
|
update_server_data(server, name, address, accept_textures);
|
|
write_servers(&path, &servers).await
|
|
}
|
|
|
|
async fn remove_server_locked(
|
|
metadata: &InstanceMetadata,
|
|
server_id: &str,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
if !participating(metadata, state).await? {
|
|
return Err(ErrorKind::InputError(
|
|
"Stable server removal requires multiplayer syncing for this instance."
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let records = compose_records(metadata, state).await?;
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.id == server_id)
|
|
.ok_or_else(|| ErrorKind::InputError("Unknown server".to_string()))?;
|
|
if record.source == ServerSource::UserSynced {
|
|
let mut canonical = read_canonical(state).await?;
|
|
canonical.retain(|server| server.id != server_id);
|
|
commit_server_state(Some(&canonical), None, state).await?;
|
|
regenerate_servers(state).await
|
|
} else {
|
|
let mut locals = load_local(&metadata.instance.id, state).await?;
|
|
locals.retain(|server| server.id != server_id);
|
|
write_local(&metadata.instance.id, &locals, state).await?;
|
|
compose_instance(metadata, state).await
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn remove_server_by_index(
|
|
metadata: &InstanceMetadata,
|
|
index: usize,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let _guard = state.lock_synced_options().await;
|
|
if participating(metadata, state).await? {
|
|
let records = list_server_records_locked(metadata, state).await?;
|
|
let record = records
|
|
.get(index)
|
|
.filter(|record| !record.hidden())
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"No removable server at index {index}"
|
|
))
|
|
})?;
|
|
return remove_server_locked(metadata, &record.id, state).await;
|
|
}
|
|
let path = instance_game_dir(metadata, state)?.join(SERVERS_FILE);
|
|
let mut servers = read_servers(&path).await?;
|
|
if servers.get(index).is_none_or(server_hidden) {
|
|
return Err(ErrorKind::InputError(format!(
|
|
"No removable server at index {index}"
|
|
))
|
|
.into());
|
|
}
|
|
servers.remove(index);
|
|
write_servers(&path, &servers).await
|
|
}
|
|
|
|
pub async fn desync_server(
|
|
instance_id: &str,
|
|
server_id: &str,
|
|
mode: DesyncServerMode,
|
|
) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let _guard = state.lock_synced_options().await;
|
|
let metadata = crate::state::get_instance(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?;
|
|
if !participating(&metadata, &state).await? {
|
|
return Err(ErrorKind::InputError(
|
|
"This instance is not participating in multiplayer server syncing."
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let mut canonical = read_canonical(&state).await?;
|
|
let server = canonical
|
|
.iter()
|
|
.find(|server| server.id == server_id)
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(
|
|
"Only a synced user server can be desynced.".to_string(),
|
|
)
|
|
})?;
|
|
let local_position = compose_records(&metadata, &state)
|
|
.await?
|
|
.iter()
|
|
.position(|record| record.id == server_id)
|
|
.unwrap_or_default() as i64;
|
|
let mut locals = load_local(instance_id, &state).await?;
|
|
locals.push(LocalServer {
|
|
id: Uuid::new_v4().to_string(),
|
|
source: ServerSource::LocalDesynced,
|
|
excluded_synced_server_id: (mode
|
|
== DesyncServerMode::KeepInOtherInstances)
|
|
.then(|| server.id.clone()),
|
|
data: server.data,
|
|
position: local_position,
|
|
});
|
|
if mode == DesyncServerMode::RemoveFromOtherInstances {
|
|
canonical.retain(|candidate| candidate.id != server_id);
|
|
}
|
|
let canonical_changed = commit_server_state(
|
|
Some(&canonical),
|
|
Some((instance_id, &locals)),
|
|
&state,
|
|
)
|
|
.await?;
|
|
if canonical_changed {
|
|
regenerate_servers(&state).await?;
|
|
} else {
|
|
compose_instance(&metadata, &state).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_synced_servers() -> crate::Result<Vec<SyncedServer>> {
|
|
let state = State::get().await?;
|
|
let _guard = state.lock_synced_options().await;
|
|
Ok(read_canonical(&state)
|
|
.await?
|
|
.into_iter()
|
|
.map(|server| {
|
|
let CanonicalServer { id, data } = server;
|
|
SyncedServer {
|
|
id,
|
|
name: data
|
|
.get::<_, &str>("name")
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
address: server_address(&data),
|
|
accept_textures: data
|
|
.get::<_, i8>("acceptTextures")
|
|
.ok()
|
|
.map(|value| value != 0),
|
|
}
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn update_synced_server(server: SyncedServer) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let _guard = state.lock_synced_options().await;
|
|
let mut canonical = read_canonical(&state).await?;
|
|
let target = canonical
|
|
.iter_mut()
|
|
.find(|candidate| candidate.id == server.id)
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError("Unknown synced server".to_string())
|
|
})?;
|
|
update_server_data(
|
|
&mut target.data,
|
|
server.name,
|
|
server.address,
|
|
server.accept_textures,
|
|
);
|
|
commit_server_state(Some(&canonical), None, &state).await?;
|
|
regenerate_servers(&state).await
|
|
}
|
|
|
|
pub async fn remove_synced_server(server_id: &str) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let _guard = state.lock_synced_options().await;
|
|
let mut canonical = read_canonical(&state).await?;
|
|
let previous_len = canonical.len();
|
|
canonical.retain(|server| server.id != server_id);
|
|
if canonical.len() == previous_len {
|
|
return Err(
|
|
ErrorKind::InputError("Unknown synced server".to_string()).into()
|
|
);
|
|
}
|
|
commit_server_state(Some(&canonical), None, &state).await?;
|
|
regenerate_servers(&state).await
|
|
}
|
|
|
|
pub(super) async fn compose_instance(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let records = compose_records(metadata, state).await?;
|
|
let bytes = servers_to_bytes(
|
|
&records
|
|
.iter()
|
|
.map(|record| record.data.clone())
|
|
.collect::<Vec<_>>(),
|
|
)?;
|
|
let expected = sha1_bytes(&bytes);
|
|
let revision = server_revision(state).await?;
|
|
begin_server_checkpoint(
|
|
&metadata.instance.id,
|
|
&records,
|
|
&expected,
|
|
revision,
|
|
state,
|
|
)
|
|
.await?;
|
|
let generated = generated_path(state, &metadata.instance.id);
|
|
if let Some(parent) = generated.parent() {
|
|
crate::util::io::create_dir_all(parent).await?;
|
|
}
|
|
crate::util::io::write(&generated, &bytes).await?;
|
|
let target = instance_game_dir(metadata, state)?.join(SERVERS_FILE);
|
|
let mode = ensure_link(&generated, &target).await?;
|
|
finish_checkpoint(
|
|
&metadata.instance.id,
|
|
SyncedOption::MultiplayerServers,
|
|
"default",
|
|
mode,
|
|
state,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn compose_records(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<Vec<ServerRecord>> {
|
|
let canonical = read_canonical(state).await?;
|
|
let locals = load_local(&metadata.instance.id, state).await?;
|
|
Ok(merge_server_records(canonical, locals))
|
|
}
|
|
|
|
fn merge_server_records(
|
|
canonical: Vec<CanonicalServer>,
|
|
locals: Vec<LocalServer>,
|
|
) -> Vec<ServerRecord> {
|
|
let exclusions = locals
|
|
.iter()
|
|
.filter_map(|server| server.excluded_synced_server_id.as_deref())
|
|
.collect::<HashSet<_>>();
|
|
let mut records = canonical
|
|
.into_iter()
|
|
.filter(|server| !exclusions.contains(server.id.as_str()))
|
|
.map(|server| ServerRecord {
|
|
id: server.id,
|
|
source: ServerSource::UserSynced,
|
|
data: server.data,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let mut locals = locals;
|
|
locals.sort_by_key(|server| server.position);
|
|
for server in locals {
|
|
let position = usize::try_from(server.position)
|
|
.unwrap_or(records.len())
|
|
.min(records.len());
|
|
records.insert(
|
|
position,
|
|
ServerRecord {
|
|
id: server.id,
|
|
source: server.source,
|
|
data: server.data,
|
|
},
|
|
);
|
|
}
|
|
records
|
|
}
|
|
|
|
async fn regenerate_servers(state: &State) -> crate::Result<()> {
|
|
for metadata in crate::state::list_instances(&state.pool).await? {
|
|
if effective(&metadata, state).await? {
|
|
compose_instance(&metadata, state).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn effective(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<bool> {
|
|
if sync_files_are_protected(metadata)
|
|
|| instance_is_running(metadata, state).await?
|
|
{
|
|
return Ok(false);
|
|
}
|
|
participating(metadata, state).await
|
|
}
|
|
|
|
async fn participating(
|
|
metadata: &InstanceMetadata,
|
|
state: &State,
|
|
) -> crate::Result<bool> {
|
|
if !instance_option_enabled(metadata, SyncedOption::MultiplayerServers) {
|
|
return Ok(false);
|
|
}
|
|
let global_enabled: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS(
|
|
SELECT 1 FROM sync_feature_settings
|
|
WHERE feature = 'multiplayer_servers' AND globally_enabled = 1
|
|
)",
|
|
)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
|
|
Ok(instance_option_supported(
|
|
metadata,
|
|
SyncedOption::MultiplayerServers,
|
|
global_enabled,
|
|
state,
|
|
)
|
|
.await)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn server(name: &str, address: &str) -> NbtCompound {
|
|
super::super::codec::server_data(
|
|
name.to_string(),
|
|
address.to_string(),
|
|
None,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn merge_preserves_local_entries_and_synced_exclusions() {
|
|
let records = merge_server_records(
|
|
vec![
|
|
CanonicalServer {
|
|
id: "synced-a".to_string(),
|
|
data: server("Synced A", "a.example.test"),
|
|
},
|
|
CanonicalServer {
|
|
id: "synced-b".to_string(),
|
|
data: server("Synced B", "b.example.test"),
|
|
},
|
|
],
|
|
vec![
|
|
LocalServer {
|
|
id: "local".to_string(),
|
|
source: ServerSource::Modpack,
|
|
excluded_synced_server_id: None,
|
|
data: server("Pack server", "pack.example.test"),
|
|
position: 1,
|
|
},
|
|
LocalServer {
|
|
id: "desynced-b".to_string(),
|
|
source: ServerSource::LocalDesynced,
|
|
excluded_synced_server_id: Some("synced-b".to_string()),
|
|
data: server("Private B", "b.example.test"),
|
|
position: 2,
|
|
},
|
|
],
|
|
);
|
|
|
|
assert_eq!(
|
|
records
|
|
.iter()
|
|
.map(|record| (record.id.as_str(), record.source))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
("synced-a", ServerSource::UserSynced),
|
|
("local", ServerSource::Modpack),
|
|
("desynced-b", ServerSource::LocalDesynced),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn projection_matching_prefers_exact_then_address_then_name() {
|
|
let projections = vec![
|
|
ProjectionEntry {
|
|
id: "exact".to_string(),
|
|
owner: ProjectionOwner::Synced,
|
|
data: server("Exact", "exact.example.test"),
|
|
position: 2,
|
|
},
|
|
ProjectionEntry {
|
|
id: "address".to_string(),
|
|
owner: ProjectionOwner::Synced,
|
|
data: server("Old name", "same.example.test"),
|
|
position: 0,
|
|
},
|
|
ProjectionEntry {
|
|
id: "name".to_string(),
|
|
owner: ProjectionOwner::Instance,
|
|
data: server("Same name", "old.example.test"),
|
|
position: 1,
|
|
},
|
|
];
|
|
let current = vec![
|
|
server("Exact", "exact.example.test"),
|
|
server("New name", "same.example.test"),
|
|
server("Same name", "new.example.test"),
|
|
];
|
|
|
|
let matches = match_projection_entries(¤t, &projections);
|
|
assert_eq!(
|
|
matches
|
|
.into_iter()
|
|
.map(|projection| projection
|
|
.map(|projection| projection.id.as_str()))
|
|
.collect::<Vec<_>>(),
|
|
vec![Some("exact"), Some("address"), Some("name")]
|
|
);
|
|
}
|
|
}
|