diff --git a/packages/app-lib/src/api/instance/mod.rs b/packages/app-lib/src/api/instance/mod.rs index 0cf64a4..2da154f 100644 --- a/packages/app-lib/src/api/instance/mod.rs +++ b/packages/app-lib/src/api/instance/mod.rs @@ -17,6 +17,13 @@ mod screenshot_groups; mod screenshots; mod upgrade; pub(crate) mod synced_options; +pub(crate) mod synced_packs { + pub(crate) use super::synced_packs_axolotl::{ + capture_resource_pack_selection_change, detach, + prepare_instance_update, reconcile, seed_from_instance, + }; +} +mod synced_packs_axolotl; pub(crate) mod synced_servers; pub use self::backup::{ @@ -116,6 +123,36 @@ pub use self::screenshots::{ list_all_screenshots, list_screenshots, list_synced_screenshots, move_screenshots, save_edited_screenshot, }; +pub(crate) use self::synced_options::game_options::{ + apply_launcher_overrides as apply_game_options_launcher_overrides, + sync_before_launch as sync_game_options_before_launch, +}; +pub use self::synced_options::{ + GlobalSyncedOptions, SyncedOptionCapability, SyncedOptionJoinAction, + SyncedOptionJoinPreview, SyncedOptionJoinResolution, SyncedOptionsOverview, + get_capabilities as get_synced_option_capabilities, + get_command_history as get_synced_command_history, + get_global_options as get_global_synced_options, + get_initialized_options as get_initialized_synced_options, + get_instance_option_join_preview as get_synced_option_join_preview, + get_overview as get_synced_options_overview, + set_command_history as set_synced_command_history, + set_global_option as set_global_synced_option, + set_instance_option as set_instance_synced_option, +}; +pub(crate) use self::synced_options::{ + reconcile_changed_file as reconcile_synced_option_file, + remove_generated_instance_files, +}; +pub use self::synced_packs_axolotl::{ + PackSyncPreview, PackSyncTarget, desync_pack, get_pack_sync_preview, + list_synced_packs, remove_synced_pack, set_synced_pack_enabled, sync_pack, + upload_synced_pack, +}; +pub use self::synced_servers::{ + DesyncServerMode, ServerSource, SyncedServer, desync_server, + list_synced_servers, remove_synced_server, update_synced_server, +}; pub use self::upgrade::{ dismiss_instance_post_upgrade_notice, execute_instance_upgrade, get_instance_post_upgrade_notice, get_instance_upgrade_plan, diff --git a/packages/app-lib/src/api/instance/synced_options/command_history.rs b/packages/app-lib/src/api/instance/synced_options/command_history.rs new file mode 100644 index 0000000..a4ff6c3 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/command_history.rs @@ -0,0 +1,187 @@ +use super::COMMAND_HISTORY_FILE; +use super::files::{ + CheckpointStatus, begin_checkpoint, checkpoint, ensure_link, + finish_checkpoint, instance_game_dir, sha1_bytes, sha1_file, +}; +use super::orchestration::{ + create_synced_directories, option_effective, synced_options_path, +}; +use crate::State; +use crate::state::{InstanceMetadata, SyncedOption}; +use crate::util::io; +use std::path::PathBuf; + +const COMMAND_HISTORY_LIMIT: usize = 50; + +pub async fn get_command_history() -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let path = command_history_path(&state); + if !path.exists() { + return Ok(String::new()); + } + Ok(String::from_utf8_lossy(&io::read(path).await?).into_owned()) +} + +pub async fn set_command_history(contents: &str) -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + create_synced_directories(&state).await?; + let normalized = normalize_command_history(contents); + io::write(command_history_path(&state), normalized.as_bytes()).await?; + refresh_command_history_links(&state).await?; + Ok(normalized) +} + +pub(super) async fn ensure_command_history( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + create_synced_directories(state).await?; + let canonical = command_history_path(state); + if !canonical.exists() { + let local = + instance_game_dir(metadata, state)?.join(COMMAND_HISTORY_FILE); + let contents = if local.exists() { + String::from_utf8_lossy(&io::read(&local).await?).into_owned() + } else { + String::new() + }; + io::write(&canonical, normalize_command_history(&contents)).await?; + } + let target = instance_game_dir(metadata, state)?.join(COMMAND_HISTORY_FILE); + let canonical_bytes = io::read(&canonical).await?; + let expected = sha1_bytes(&canonical_bytes); + begin_checkpoint( + &metadata.instance.id, + SyncedOption::CommandHistory, + "default", + &expected, + None, + 0, + state, + ) + .await?; + let mode = ensure_link(&canonical, &target).await?; + finish_checkpoint( + &metadata.instance.id, + SyncedOption::CommandHistory, + "default", + mode, + state, + ) + .await +} + +pub(super) async fn reconcile_command_history( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if !option_effective(metadata, SyncedOption::CommandHistory, state).await? { + return Ok(()); + } + let local = instance_game_dir(metadata, state)?.join(COMMAND_HISTORY_FILE); + if !local.exists() { + return ensure_command_history(metadata, state).await; + } + let symlink = tokio::fs::symlink_metadata(&local) + .await + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false); + let current_checkpoint = checkpoint( + &metadata.instance.id, + SyncedOption::CommandHistory, + "default", + state, + ) + .await?; + if current_checkpoint + .as_ref() + .is_some_and(|value| value.status == CheckpointStatus::Pending) + { + return ensure_command_history(metadata, state).await; + } + let actual = sha1_file(&local).await?; + let expected = current_checkpoint.map(|value| value.expected_sha1); + if !symlink && expected.as_deref() != Some(actual.as_str()) { + let contents = + String::from_utf8_lossy(&io::read(&local).await?).into_owned(); + io::write( + command_history_path(state), + normalize_command_history(&contents), + ) + .await?; + refresh_command_history_links(state).await?; + } else { + ensure_command_history(metadata, state).await?; + } + Ok(()) +} + +async fn refresh_command_history_links(state: &State) -> crate::Result<()> { + let instances = crate::state::list_instances(&state.pool).await?; + for metadata in instances { + if option_effective(&metadata, SyncedOption::CommandHistory, state) + .await? + { + ensure_command_history(&metadata, state).await?; + } + } + Ok(()) +} + +pub(super) fn normalize_command_history(contents: &str) -> String { + let lines = contents.lines().collect::>(); + let start = lines.len().saturating_sub(COMMAND_HISTORY_LIMIT); + let mut normalized = lines[start..].join("\n"); + if !normalized.is_empty() { + normalized.push('\n'); + } + normalized +} + +pub(super) async fn merge_command_history_from_instance( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let canonical_path = command_history_path(state); + let canonical = if canonical_path.exists() { + String::from_utf8_lossy(&io::read(&canonical_path).await?).into_owned() + } else { + String::new() + }; + let local_path = + instance_game_dir(metadata, state)?.join(COMMAND_HISTORY_FILE); + let local = if local_path.exists() { + String::from_utf8_lossy(&io::read(&local_path).await?).into_owned() + } else { + String::new() + }; + let canonical_lines = canonical.lines().collect::>(); + let available = COMMAND_HISTORY_LIMIT.saturating_sub(canonical_lines.len()); + let mut seen = canonical_lines + .iter() + .copied() + .collect::>(); + let mut imported = Vec::new(); + for line in local.lines().rev() { + if imported.len() == available { + break; + } + if seen.insert(line) { + imported.push(line); + } + } + imported.reverse(); + imported.extend(canonical_lines); + let merged = normalize_command_history(&imported.join("\n")); + if merged != normalize_command_history(&canonical) { + io::write(&canonical_path, merged).await?; + refresh_command_history_links(state).await?; + } + Ok(()) +} + +pub(super) fn command_history_path(state: &State) -> PathBuf { + synced_options_path(state).join(COMMAND_HISTORY_FILE) +} diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs b/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs index 08c86cf..2ad77e6 100644 --- a/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs +++ b/packages/app-lib/src/api/instance/synced_options/game_options/mod.rs @@ -22,6 +22,7 @@ pub(crate) mod locales; mod options_file; mod pack_updates; mod read_instance_changes; +mod resource_pack_options; mod settings_editor; mod source_selection; mod write_shared_settings; @@ -54,6 +55,10 @@ pub(crate) use write_shared_settings::sync_all_participating_instances; pub(in crate::api::instance) use pack_updates::{ detach_instance, prepare_instance_update_with_state, }; +pub(in crate::api::instance) use resource_pack_options::{ + ResourcePackOptionsUpdate, merge_resource_pack_entries, + merge_resource_pack_order, read_resource_pack_entries, +}; pub(in crate::api::instance) use source_selection::initialize_from_source_instance; pub(in crate::api::instance) use write_shared_settings::{ apply_instance_with_state, capture_instance_file_change, diff --git a/packages/app-lib/src/api/instance/synced_options/game_options/resource_pack_options.rs b/packages/app-lib/src/api/instance/synced_options/game_options/resource_pack_options.rs new file mode 100644 index 0000000..676c2fe --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/game_options/resource_pack_options.rs @@ -0,0 +1,112 @@ +//! Reads and merges Minecraft's active resource-pack list. + +use super::super::{instance_is_running, sync_files_are_protected}; +use super::options_file::{input_error, options_path, read_document}; +use crate::state::{InstanceMetadata, State}; +use crate::util::io; +use std::collections::BTreeSet; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::api::instance) struct ResourcePackOptions { + pub entries: Vec, + pub incompatible: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::api::instance) enum ResourcePackOptionsUpdate { + Deferred, + Applied(ResourcePackOptions), +} + +fn parse_entries(raw: &str) -> crate::Result> { + serde_json::from_str(raw).map_err(|_| { + input_error("options.txt contains an invalid resourcePacks value") + }) +} + +pub(in crate::api::instance) fn merge_resource_pack_order( + entries: &[String], + managed: &BTreeSet, + selected: &[String], +) -> Vec { + let mut merged = Vec::new(); + let mut remaining = selected.iter(); + let mut seen = BTreeSet::new(); + for entry in entries { + if !managed.contains(entry) { + merged.push(entry.clone()); + } else if selected.contains(entry) + && seen.insert(entry) + && let Some(next) = remaining.next() + { + merged.push(next.clone()); + } + } + merged.extend(remaining.cloned()); + merged +} + +pub(in crate::api::instance) async fn read_resource_pack_entries( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result> { + let path = options_path(metadata, state)?; + if !path.exists() { + return Ok(None); + } + let (document, _) = read_document(&path).await?; + let Some(raw) = document.value("resourcePacks") else { + return Ok(None); + }; + Ok(Some(ResourcePackOptions { + entries: parse_entries(raw)?, + incompatible: document + .value("incompatibleResourcePacks") + .map(parse_entries) + .transpose()? + .unwrap_or_default(), + })) +} + +pub(in crate::api::instance) async fn merge_resource_pack_entries( + metadata: &InstanceMetadata, + managed: &BTreeSet, + selected: &[String], + state: &State, +) -> crate::Result { + if sync_files_are_protected(metadata) { + return Ok(ResourcePackOptionsUpdate::Deferred); + } + let path = options_path(metadata, state)?; + if !path.exists() { + return Ok(ResourcePackOptionsUpdate::Deferred); + } + let (mut document, input_bytes) = read_document(&path).await?; + let Some(raw) = document.value("resourcePacks") else { + return Ok(ResourcePackOptionsUpdate::Deferred); + }; + let original = parse_entries(raw)?; + let entries = merge_resource_pack_order(&original, managed, selected); + let options = ResourcePackOptions { + entries, + incompatible: document + .value("incompatibleResourcePacks") + .map(parse_entries) + .transpose()? + .unwrap_or_default(), + }; + if options.entries == original { + return Ok(ResourcePackOptionsUpdate::Applied(options)); + } + if instance_is_running(metadata, state).await? { + return Ok(ResourcePackOptionsUpdate::Deferred); + } + let raw = serde_json::to_string(&options.entries)?; + document.set("resourcePacks", &raw, false)?; + let output_bytes = document.serialize()?; + if output_bytes == input_bytes { + return Ok(ResourcePackOptionsUpdate::Applied(options)); + } + io::write(path, output_bytes).await?; + Ok(ResourcePackOptionsUpdate::Applied(options)) +} diff --git a/packages/app-lib/src/api/instance/synced_options/hotbars.rs b/packages/app-lib/src/api/instance/synced_options/hotbars.rs new file mode 100644 index 0000000..1dc58f1 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/hotbars.rs @@ -0,0 +1,602 @@ +use super::HOTBAR_FILE; +use super::files::{ + CheckpointStatus, begin_checkpoint, checkpoint, ensure_link, + finish_checkpoint, instance_game_dir, nbt_from_bytes, nbt_to_bytes, + read_nbt_file, safe_instance_id, sha1_bytes, sha1_file, +}; +use super::orchestration::{ + backup_bytes, create_synced_directories, option_effective, + seed_from_instance, synced_options_path, +}; +use crate::state::{InstanceMetadata, SyncedOption}; +use crate::util::io; +use crate::{ErrorKind, State}; +use quartz_nbt::{NbtCompound, NbtList, NbtTag}; +use std::path::PathBuf; + +const COMPONENTS_DATA_VERSION_FLOOR: i32 = 3837; +pub(super) const HOTBAR_SCHEMA_VERSION: i64 = 2; + +pub(super) struct HotbarState { + pub schema_version: i64, + pub revision: i64, + pub nbt: NbtCompound, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HotbarFamily { + Legacy, + Components, +} + +impl HotbarFamily { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Legacy => "legacy", + Self::Components => "components", + } + } + + fn other(self) -> Self { + match self { + Self::Legacy => Self::Components, + Self::Components => Self::Legacy, + } + } +} + +pub(super) async fn hotbar_family( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let (manifest, version_index) = + crate::launcher::resolve_minecraft_manifest( + &metadata.applied_content_set.game_version, + state, + ) + .await?; + let cutoff = manifest + .versions + .iter() + .find(|item| item.id == "1.20.5") + .ok_or_else(|| { + ErrorKind::LauncherError( + "Minecraft 1.20.5 is missing from the version manifest" + .to_string(), + ) + })?; + Ok( + if manifest.versions[version_index].release_time >= cutoff.release_time + { + HotbarFamily::Components + } else { + HotbarFamily::Legacy + }, + ) +} + +pub(super) async fn backup_shared_hotbars(state: &State) -> crate::Result<()> { + let sync_state = read_hotbar_state(state).await?; + let legacy = hotbar_family_root(&sync_state.nbt, HotbarFamily::Legacy); + let components = + hotbar_family_root(&sync_state.nbt, HotbarFamily::Components); + backup_bytes( + "shared", + "hotbar-legacy.nbt", + &nbt_to_bytes(&legacy)?, + state, + ) + .await?; + backup_bytes( + "shared", + "hotbar-components.nbt", + &nbt_to_bytes(&components)?, + state, + ) + .await?; + backup_bytes( + "shared", + "hotbars-state.nbt", + &nbt_to_bytes(&sync_state.nbt)?, + state, + ) + .await +} + +pub(super) async fn ensure_hotbar( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + create_synced_directories(state).await?; + if !hotbar_state_exists(state).await? { + seed_from_instance(metadata, SyncedOption::CreativeHotbars, state) + .await?; + } + write_hotbar_projection(metadata, state).await +} + +pub(super) async fn reconcile_hotbar( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + if !option_effective(metadata, SyncedOption::CreativeHotbars, state).await? + { + return Ok(()); + } + let local = instance_game_dir(metadata, state)?.join(HOTBAR_FILE); + if !local.exists() { + return ensure_hotbar(metadata, state).await; + } + let family = hotbar_family(metadata, state).await?; + let current_checkpoint = checkpoint( + &metadata.instance.id, + SyncedOption::CreativeHotbars, + family.as_str(), + state, + ) + .await?; + if current_checkpoint + .as_ref() + .is_some_and(|value| value.status == CheckpointStatus::Pending) + { + return write_hotbar_projection(metadata, state).await; + } + let actual = sha1_file(&local).await?; + let mut sync_state = read_hotbar_state(state).await?; + if current_checkpoint + .as_ref() + .map(|value| value.expected_sha1.as_str()) + == Some(actual.as_str()) + { + if current_checkpoint + .as_ref() + .is_some_and(|value| value.source_revision == sync_state.revision) + { + return Ok(()); + } + return write_hotbar_projection(metadata, state).await; + } + + let (changed_family, checkpoint) = if current_checkpoint.is_none() { + let previous_family = family.other(); + let previous_checkpoint = checkpoint( + &metadata.instance.id, + SyncedOption::CreativeHotbars, + previous_family.as_str(), + state, + ) + .await?; + if let Some(previous_checkpoint) = previous_checkpoint { + if previous_checkpoint.status == CheckpointStatus::Pending + || previous_checkpoint.expected_sha1 == actual + { + return write_hotbar_projection(metadata, state).await; + } + (previous_family, Some(previous_checkpoint)) + } else { + (family, None) + } + } else { + (family, current_checkpoint) + }; + + let changed = read_nbt_file(&local).await?; + let merge_base = checkpoint + .and_then(|value| value.merge_base) + .map(nbt_from_bytes) + .transpose()? + .unwrap_or_else(|| hotbar_family_root(&sync_state.nbt, changed_family)); + if merge_hotbar_family( + &mut sync_state.nbt, + changed_family, + &merge_base, + &changed, + ) { + increment_hotbar_revision(&mut sync_state); + write_hotbar_state(state, &sync_state).await?; + } + regenerate_hotbars(state).await +} + +pub(super) async fn instance_hotbars_differ_from_synced( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result { + let local_path = instance_game_dir(metadata, state)?.join(HOTBAR_FILE); + if !local_path.exists() { + return Ok(false); + } + let local = read_nbt_file(&local_path).await?; + let family = hotbar_family(metadata, state).await?; + let synced = + hotbar_family_root(&read_hotbar_state(state).await?.nbt, family); + Ok((0..81) + .any(|slot| hotbar_slot(&local, slot) != hotbar_slot(&synced, slot))) +} + +pub(super) fn merge_hotbar_family( + state: &mut NbtCompound, + family: HotbarFamily, + merge_base: &NbtCompound, + changed: &NbtCompound, +) -> bool { + let family_key = family_state_key(family); + let mut current = state + .get::<_, &NbtCompound>(family_key) + .ok() + .cloned() + .unwrap_or_else(empty_hotbar_root); + let other_family = match family { + HotbarFamily::Legacy => HotbarFamily::Components, + HotbarFamily::Components => HotbarFamily::Legacy, + }; + let other_key = family_state_key(other_family); + let other = state.get::<_, &NbtCompound>(other_key).ok().cloned(); + let seed_components_with_legacy = + other.is_none() && family == HotbarFamily::Legacy; + let mut other_root = if seed_components_with_legacy { + (*changed).clone() + } else { + other.unwrap_or_else(empty_hotbar_root) + }; + let mut revisions = state + .get::<_, &NbtCompound>("Revisions") + .ok() + .cloned() + .unwrap_or_default(); + let family_versions_key = family_data_versions_key(family); + let other_versions_key = family_data_versions_key(other_family); + let mut family_versions = state + .get::<_, &NbtCompound>(family_versions_key) + .ok() + .cloned() + .unwrap_or_default(); + let mut other_versions = state + .get::<_, &NbtCompound>(other_versions_key) + .ok() + .cloned() + .unwrap_or_default(); + let writer_data_version = hotbar_data_version(changed).max(match family { + HotbarFamily::Legacy => 1, + HotbarFamily::Components => COMPONENTS_DATA_VERSION_FLOOR, + }); + let current_data_version = hotbar_data_version(¤t); + let other_data_version = hotbar_data_version(&other_root); + let mut changed_any = false; + + for slot in 0..81 { + let old_slot = hotbar_slot(merge_base, slot); + let new_slot = hotbar_slot(changed, slot); + if old_slot == new_slot { + continue; + } + let current_origin = family_versions + .get::<_, i32>(&slot.to_string()) + .unwrap_or(current_data_version); + if writer_data_version > 0 + && current_origin > 0 + && writer_data_version < current_origin + { + continue; + } + set_hotbar_slot_optional(&mut current, slot, new_slot.clone()); + family_versions.insert(slot.to_string(), writer_data_version); + let revision = revisions + .get::<_, i64>(&slot.to_string()) + .unwrap_or(0) + .saturating_add(1); + revisions.insert(slot.to_string(), revision); + if seed_components_with_legacy { + other_versions.insert(slot.to_string(), writer_data_version); + } else if let Some(slot_value) = new_slot + .and_then(|value| convert_hotbar_slot(value, family, other_family)) + { + let other_origin = other_versions + .get::<_, i32>(&slot.to_string()) + .unwrap_or(other_data_version); + if writer_data_version <= 0 + || other_origin <= 0 + || writer_data_version >= other_origin + { + set_hotbar_slot(&mut other_root, slot, slot_value); + other_versions.insert(slot.to_string(), writer_data_version); + } + } + changed_any = true; + } + + if writer_data_version > current_data_version { + current.insert("DataVersion", writer_data_version); + changed_any = true; + } + if seed_components_with_legacy + && writer_data_version > hotbar_data_version(&other_root) + { + other_root.insert("DataVersion", writer_data_version); + } + + state.insert(family_key, current); + state.insert(other_key, other_root); + state.insert("Revisions", revisions); + state.insert(family_versions_key, family_versions); + state.insert(other_versions_key, other_versions); + changed_any +} + +fn convert_hotbar_slot( + value: NbtTag, + from: HotbarFamily, + to: HotbarFamily, +) -> Option { + if from == to { + return Some(value); + } + let NbtTag::Compound(item) = value else { + return None; + }; + if item.is_empty() { + return Some(NbtCompound::new().into()); + } + let id = item.get::<_, &str>("id").ok()?.to_string(); + + match (from, to) { + (HotbarFamily::Legacy, HotbarFamily::Components) => { + if item.contains_key("tag") { + return None; + } + let count = item + .get::<_, i8>("Count") + .map(i32::from) + .or_else(|_| item.get::<_, i32>("Count")) + .unwrap_or(1); + let mut converted = NbtCompound::new(); + converted.insert("id", id); + converted.insert("count", count); + Some(converted.into()) + } + (HotbarFamily::Components, HotbarFamily::Legacy) => { + if item + .get::<_, &NbtCompound>("components") + .is_ok_and(|components| !components.is_empty()) + { + return None; + } + let count = item.get::<_, i32>("count").unwrap_or(1); + let count = i8::try_from(count).ok()?; + let mut converted = NbtCompound::new(); + converted.insert("id", id); + converted.insert("Count", count); + Some(converted.into()) + } + _ => Some(item.into()), + } +} + +fn hotbar_slot(root: &NbtCompound, slot: usize) -> Option { + let toolbar = (slot / 9).to_string(); + let position = slot % 9; + root.get::<_, &NbtList>(&toolbar) + .ok() + .and_then(|list| list.as_ref().get(position).cloned()) +} + +fn set_hotbar_slot(root: &mut NbtCompound, slot: usize, value: NbtTag) { + let toolbar = (slot / 9).to_string(); + let position = slot % 9; + let mut list = root + .get::<_, &NbtList>(&toolbar) + .ok() + .cloned() + .unwrap_or_default(); + while list.len() < 9 { + list.push(NbtCompound::new()); + } + list[position] = value; + root.insert(toolbar, list); +} + +fn set_hotbar_slot_optional( + root: &mut NbtCompound, + slot: usize, + value: Option, +) { + set_hotbar_slot( + root, + slot, + value.unwrap_or_else(|| NbtCompound::new().into()), + ); +} + +fn hotbar_data_version(root: &NbtCompound) -> i32 { + root.get::<_, i32>("DataVersion").unwrap_or(0) +} + +fn family_data_versions_key(family: HotbarFamily) -> &'static str { + match family { + HotbarFamily::Legacy => "LegacyDataVersions", + HotbarFamily::Components => "ComponentsDataVersions", + } +} + +pub(super) async fn regenerate_hotbars(state: &State) -> crate::Result<()> { + let instances = crate::state::list_instances(&state.pool).await?; + for metadata in instances { + if option_effective(&metadata, SyncedOption::CreativeHotbars, state) + .await? + { + write_hotbar_projection(&metadata, state).await?; + } + } + Ok(()) +} + +async fn write_hotbar_projection( + metadata: &InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let sync_state = read_hotbar_state(state).await?; + let family = hotbar_family(metadata, state).await?; + let root = hotbar_family_root(&sync_state.nbt, family); + let bytes = nbt_to_bytes(&root)?; + let expected = sha1_bytes(&bytes); + let generated = generated_hotbar_path(state, family, &metadata.instance.id); + begin_checkpoint( + &metadata.instance.id, + SyncedOption::CreativeHotbars, + family.as_str(), + &expected, + Some(&bytes), + sync_state.revision, + state, + ) + .await?; + if let Some(parent) = generated.parent() { + io::create_dir_all(parent).await?; + } + io::write(&generated, &bytes).await?; + let local = instance_game_dir(metadata, state)?.join(HOTBAR_FILE); + let mode = ensure_link(&generated, &local).await?; + finish_checkpoint( + &metadata.instance.id, + SyncedOption::CreativeHotbars, + family.as_str(), + mode, + state, + ) + .await?; + remove_other_hotbar_checkpoint(&metadata.instance.id, family, state).await +} + +async fn remove_other_hotbar_checkpoint( + instance_id: &str, + family: HotbarFamily, + state: &State, +) -> crate::Result<()> { + let variant = family.as_str(); + sqlx::query( + "DELETE FROM instance_sync_checkpoints + WHERE instance_id = ? + AND feature = 'creative_hotbars' + AND variant != ?", + ) + .bind(instance_id) + .bind(variant) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(super) fn empty_hotbar_root() -> NbtCompound { + let mut root = NbtCompound::new(); + for toolbar in 0..9 { + let mut items = NbtList::new(); + for _ in 0..9 { + items.push(NbtCompound::new()); + } + root.insert(toolbar.to_string(), items); + } + root +} + +pub(super) async fn read_hotbar_state( + state: &State, +) -> crate::Result { + let row = sqlx::query( + "SELECT schema_version, revision, nbt + FROM synced_hotbar_state + WHERE singleton = 1", + ) + .fetch_optional(&state.pool) + .await?; + if let Some(row) = row { + use sqlx::Row; + let schema_version: i64 = row.get("schema_version"); + let revision: i64 = row.get("revision"); + let nbt: Vec = row.get("nbt"); + return Ok(HotbarState { + schema_version, + revision, + nbt: nbt_from_bytes(nbt)?, + }); + } + Ok(HotbarState { + schema_version: HOTBAR_SCHEMA_VERSION, + revision: 0, + nbt: NbtCompound::new(), + }) +} + +pub(super) async fn hotbar_state_exists(state: &State) -> crate::Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM synced_hotbar_state WHERE singleton = 1 + )", + ) + .fetch_one(&state.pool) + .await?) +} + +pub(super) fn hotbar_family_root( + state: &NbtCompound, + family: HotbarFamily, +) -> NbtCompound { + let legacy = state + .get::<_, &NbtCompound>("Legacy") + .ok() + .cloned() + .unwrap_or_else(empty_hotbar_root); + match family { + HotbarFamily::Legacy => legacy, + HotbarFamily::Components => state + .get::<_, &NbtCompound>("Components") + .ok() + .cloned() + .unwrap_or(legacy), + } +} + +pub(super) fn increment_hotbar_revision(state: &mut HotbarState) { + state.schema_version = HOTBAR_SCHEMA_VERSION; + state.revision = state.revision.saturating_add(1); +} + +pub(super) async fn write_hotbar_state( + state: &State, + hotbar_state: &HotbarState, +) -> crate::Result<()> { + let bytes = nbt_to_bytes(&hotbar_state.nbt)?; + sqlx::query( + "INSERT INTO synced_hotbar_state + (singleton, schema_version, revision, nbt) + VALUES (1, ?, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + schema_version = excluded.schema_version, + revision = excluded.revision, + nbt = excluded.nbt", + ) + .bind(hotbar_state.schema_version) + .bind(hotbar_state.revision) + .bind(bytes) + .execute(&state.pool) + .await?; + Ok(()) +} + +fn family_state_key(family: HotbarFamily) -> &'static str { + match family { + HotbarFamily::Legacy => "Legacy", + HotbarFamily::Components => "Components", + } +} + +pub(super) fn generated_hotbar_path( + state: &State, + family: HotbarFamily, + instance_id: &str, +) -> PathBuf { + synced_options_path(state) + .join("hotbars/generated") + .join(family.as_str()) + .join(safe_instance_id(instance_id)) + .join(HOTBAR_FILE) +} diff --git a/packages/app-lib/src/api/instance/synced_options/mod.rs b/packages/app-lib/src/api/instance/synced_options/mod.rs index 142bfd3..d1b4bff 100644 --- a/packages/app-lib/src/api/instance/synced_options/mod.rs +++ b/packages/app-lib/src/api/instance/synced_options/mod.rs @@ -1,14 +1,32 @@ +mod command_history; mod files; -mod game_options; +pub(crate) mod game_options; +mod hotbars; +mod orchestration; pub(in crate::api::instance) mod pending; const COMMAND_HISTORY_FILE: &str = "command_history.txt"; const HOTBAR_FILE: &str = "hotbar.nbt"; +pub use self::command_history::{get_command_history, set_command_history}; pub(super) use self::files::{ CheckpointStatus, begin_checkpoint, checkpoint, detach_link, ensure_link, finish_checkpoint, finish_plain_checkpoint, instance_game_dir, instance_is_running, instance_option_enabled, nbt_from_bytes, nbt_to_bytes, option_can_apply_while_running, read_nbt_file, safe_instance_id, sha1_bytes, sha1_file, sync_files_are_protected, synced_options_path, +}; +pub(super) use self::orchestration::instance_option_supported; +pub use self::orchestration::{ + GlobalSyncedOptions, SyncedOptionCapability, SyncedOptionJoinAction, + SyncedOptionJoinPreview, SyncedOptionJoinResolution, SyncedOptionsOverview, + get_capabilities, get_global_options, get_initialized_options, + get_instance_option_join_preview, get_overview, get_synced_options_folder, + reconcile_all, reconcile_changed_file, reconcile_instance, + set_global_option, set_instance_option, +}; +pub(crate) use self::orchestration::{ + monitor_persisted_processes, prepare_instance_update, + reconcile_instance_after_pack_update_with_state, + remove_generated_instance_files, }; \ No newline at end of file diff --git a/packages/app-lib/src/api/instance/synced_options/orchestration.rs b/packages/app-lib/src/api/instance/synced_options/orchestration.rs new file mode 100644 index 0000000..ce2cb63 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_options/orchestration.rs @@ -0,0 +1,1623 @@ +use crate::state::instances::adapters::sqlite::instance_rows; +use crate::state::{InstanceLink, InstanceMetadata, SyncedOption}; +use crate::util::io; +use crate::{ErrorKind, State}; +use quartz_nbt::NbtCompound; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use std::future::Future; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::super::{synced_packs, synced_servers}; +use super::command_history::{ + command_history_path, ensure_command_history, + merge_command_history_from_instance, normalize_command_history, + reconcile_command_history, +}; +use super::files::{ + begin_checkpoint, detach_link, instance_game_dir, instance_is_running, + instance_option_enabled, option_can_apply_while_running, read_nbt_file, + safe_instance_id, sync_files_are_protected, synced_options_path, +}; +use super::hotbars::{ + HOTBAR_SCHEMA_VERSION, HotbarState, backup_shared_hotbars, + empty_hotbar_root, ensure_hotbar, generated_hotbar_path, hotbar_family, + hotbar_state_exists, increment_hotbar_revision, + instance_hotbars_differ_from_synced, merge_hotbar_family, + read_hotbar_state, reconcile_hotbar, regenerate_hotbars, + write_hotbar_state, +}; +use super::pending::{self, PendingAction, PendingChange, SyncedOptionJoinResolution}; +use super::{COMMAND_HISTORY_FILE, HOTBAR_FILE}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncedOptionJoinAction { + SeedShared, + Attach, + Merge, + RequiresResolution, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct SyncedOptionJoinPreview { + pub action: SyncedOptionJoinAction, +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] +pub struct GlobalSyncedOptions { + pub game_options: bool, + pub command_history: bool, + pub multiplayer_servers: bool, + pub creative_hotbars: bool, + pub screenshots: bool, + #[serde(default)] + pub resource_packs: bool, + #[serde(default)] + pub data_packs: bool, +} + +impl GlobalSyncedOptions { + pub fn get(self, option: SyncedOption) -> bool { + if !option.is_available() { + return false; + } + match option { + SyncedOption::GameOptions => self.game_options, + SyncedOption::CommandHistory => self.command_history, + SyncedOption::MultiplayerServers => self.multiplayer_servers, + SyncedOption::CreativeHotbars => self.creative_hotbars, + SyncedOption::Screenshots => self.screenshots, + SyncedOption::ResourcePacks => self.resource_packs, + SyncedOption::DataPacks => self.data_packs, + } + } + + fn set(&mut self, option: SyncedOption, enabled: bool) { + let enabled = enabled && option.is_available(); + match option { + SyncedOption::GameOptions => self.game_options = enabled, + SyncedOption::CommandHistory => self.command_history = enabled, + SyncedOption::MultiplayerServers => { + self.multiplayer_servers = enabled + } + SyncedOption::CreativeHotbars => self.creative_hotbars = enabled, + SyncedOption::Screenshots => self.screenshots = enabled, + SyncedOption::ResourcePacks => self.resource_packs = enabled, + SyncedOption::DataPacks => self.data_packs = enabled, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SyncedOptionCapability { + pub option: SyncedOption, + pub supported: bool, + pub disabled_reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SyncedOptionsOverview { + pub global_options: GlobalSyncedOptions, + pub capabilities: Vec, +} + +enum CapabilityStatus { + Supported, + Unsupported(String), + Indeterminate(String), +} + +pub async fn get_global_options() -> crate::Result { + let state = State::get().await?; + get_global_options_with_state(&state).await +} + +pub async fn get_initialized_options() -> crate::Result { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + let mut options = GlobalSyncedOptions::default(); + + for option in SyncedOption::ALL { + options.set(option, canonical_exists(option, &state).await?); + } + + Ok(options) +} + +pub async fn get_synced_options_folder() -> crate::Result { + let state = State::get().await?; + create_synced_directories(&state).await?; + Ok(synced_options_path(&state)) +} + +async fn get_global_options_with_state( + state: &State, +) -> crate::Result { + let mut options = GlobalSyncedOptions::default(); + let rows = sqlx::query( + "SELECT feature, globally_enabled + FROM sync_feature_settings", + ) + .fetch_all(&state.pool) + .await?; + + use sqlx::Row; + for row in rows { + let feature: String = row.get("feature"); + let globally_enabled: bool = row.get("globally_enabled"); + if let Some(option) = option_from_str(&feature) { + options.set(option, globally_enabled); + } + } + + Ok(options) +} + +pub async fn get_overview( + instance_id: &str, +) -> crate::Result { + let state = State::get().await?; + let global_options = get_global_options_with_state(&state).await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + let mut capabilities = Vec::with_capacity(SyncedOption::ALL.len()); + + for option in SyncedOption::ALL { + capabilities.push( + capability(&metadata, option, global_options.get(option), &state) + .await, + ); + } + + Ok(SyncedOptionsOverview { + global_options, + capabilities, + }) +} + +pub async fn get_capabilities( + instance_id: &str, +) -> crate::Result> { + Ok(get_overview(instance_id).await?.capabilities) +} + +async fn capability( + metadata: &InstanceMetadata, + option: SyncedOption, + global_enabled: bool, + state: &State, +) -> SyncedOptionCapability { + let status = + capability_status(metadata, option, global_enabled, state).await; + let (supported, disabled_reason) = match status { + CapabilityStatus::Supported => (true, None), + CapabilityStatus::Unsupported(reason) + | CapabilityStatus::Indeterminate(reason) => (false, Some(reason)), + }; + + SyncedOptionCapability { + option, + supported, + disabled_reason, + } +} + +async fn capability_status( + metadata: &InstanceMetadata, + option: SyncedOption, + global_enabled: bool, + state: &State, +) -> CapabilityStatus { + if !option.is_available() { + return CapabilityStatus::Unsupported( + "Data pack syncing is currently disabled.".to_string(), + ); + } + if !global_enabled { + return CapabilityStatus::Unsupported( + "This option is disabled in the app's synced options settings." + .to_string(), + ); + } + if option == SyncedOption::MultiplayerServers + && is_linked_server_project(&metadata.link) + { + return CapabilityStatus::Unsupported( + "Multiplayer server syncing is unavailable for linked server-project instances." + .to_string(), + ); + } + match version_capability(metadata, option, state).await { + CapabilityStatus::Supported => CapabilityStatus::Supported, + status => status, + } +} + +pub(in crate::api::instance) async fn instance_option_supported( + metadata: &InstanceMetadata, + option: SyncedOption, + global_enabled: bool, + state: &State, +) -> bool { + if pending::contains(&metadata.instance.id, option, state) + .await + .unwrap_or(true) + { + return false; + } + matches!( + capability_status(metadata, option, global_enabled, state).await, + CapabilityStatus::Supported + ) +} + +async fn version_capability( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> CapabilityStatus { + if matches!( + option, + SyncedOption::GameOptions + | SyncedOption::Screenshots + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks + ) { + return CapabilityStatus::Supported; + } + + let game_version = &metadata.applied_content_set.game_version; + let Ok((manifest, version_index)) = + crate::launcher::resolve_minecraft_manifest(game_version, state).await + else { + return CapabilityStatus::Indeterminate( + "This instance’s Minecraft version could not be verified, so syncing is unavailable." + .to_string(), + ); + }; + let cutoff_id = match option { + SyncedOption::GameOptions + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks => unreachable!(), + // Mojang's manifest does not include Beta 1.8 Pre-release as a + // separate entry, so b1.8 is the first resolvable version at that + // boundary. + SyncedOption::MultiplayerServers => "b1.8", + SyncedOption::CreativeHotbars => "1.12", + SyncedOption::CommandHistory => "1.20.2", + SyncedOption::Screenshots => return CapabilityStatus::Supported, + }; + let Some(cutoff) = + manifest.versions.iter().find(|item| item.id == cutoff_id) + else { + return CapabilityStatus::Indeterminate( + "This instance’s Minecraft version could not be verified, so syncing is unavailable." + .to_string(), + ); + }; + + let version = &manifest.versions[version_index]; + let release_only_option = matches!( + option, + SyncedOption::CreativeHotbars | SyncedOption::CommandHistory + ); + let is_supported_release = + matches!(&version.type_, daedalus::minecraft::VersionType::Release); + + if version.release_time >= cutoff.release_time + && (!release_only_option || is_supported_release) + { + return CapabilityStatus::Supported; + } + + CapabilityStatus::Unsupported( + match option { + SyncedOption::GameOptions | SyncedOption::ResourcePacks | SyncedOption::DataPacks => unreachable!(), + SyncedOption::MultiplayerServers => { + "Multiplayer server syncing requires Minecraft Beta 1.8 Pre-release or newer." + } + SyncedOption::CreativeHotbars => { + "Saved creative hotbars require Minecraft 1.12 or newer." + } + SyncedOption::CommandHistory => { + "Command history syncing requires Minecraft 1.20.2 or newer." + } + SyncedOption::Screenshots => unreachable!(), + } + .to_string(), + ) +} + +pub async fn set_global_option( + option: SyncedOption, + enabled: bool, + base_instance_id: Option<&str>, +) -> crate::Result { + if !option.is_available() { + return Err(ErrorKind::InputError( + "Data pack syncing is currently disabled.".to_string(), + ) + .into()); + } + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + pending::cancel(option, None, &state).await?; + set_global_option_with_state(option, enabled, base_instance_id, &state) + .await +} + +async fn set_global_option_with_state( + option: SyncedOption, + enabled: bool, + base_instance_id: Option<&str>, + state: &State, +) -> crate::Result { + if enabled + && option != SyncedOption::Screenshots + && let Some(base_instance_id) = base_instance_id + { + if option == SyncedOption::GameOptions { + let source = + crate::state::get_instance(base_instance_id, &state.pool) + .await? + .ok_or_else(|| { + ErrorKind::InputError( + "Unknown sync source instance.".to_string(), + ) + })?; + match capability_status(&source, option, true, state).await { + CapabilityStatus::Supported => {} + CapabilityStatus::Unsupported(reason) + | CapabilityStatus::Indeterminate(reason) => { + return Err(ErrorKind::InputError(reason).into()); + } + } + if sync_files_are_protected(&source) { + return queue_source(&source, option, state).await; + } + seed_from_instance(&source, option, state).await?; + complete_pending_source(base_instance_id, option, state).await?; + } else { + return enable_global_option_from_base( + option, + base_instance_id, + state, + ) + .await; + } + } + + // Always persist the global flag and propagate the instance-level default. + // The game-options base-instance path seeds the shared values above, but + // must still enable the feature for every existing instance; otherwise + // only the selected source participates and all other instance toggles + // remain disabled. + set_global_option_enabled(option, enabled, state).await?; + + // Reload metadata after updating the global preference. The initial list + // may contain stale per-instance sync flags, which would otherwise make + // the reconciliation pass skip instances that were just enabled. + let instances = match crate::state::list_instances(&state.pool).await { + Ok(instances) => instances, + Err(error) if option == SyncedOption::GameOptions => { + tracing::warn!( + "Game-settings sync changed, but participating instances could not be listed yet: {error}" + ); + return get_global_options_with_state(state).await; + } + Err(error) => return Err(error), + }; + for metadata in instances { + if pending::contains(&metadata.instance.id, option, state).await? { + continue; + } + if !enabled + && matches!( + option, + SyncedOption::ResourcePacks | SyncedOption::DataPacks + ) + { + detach_option(&metadata, option, state).await?; + continue; + } + let running = !option_can_apply_while_running(option) + && instance_is_running(&metadata, state).await?; + if sync_files_are_protected(&metadata) || running { + continue; + } + let result = if !instance_option_enabled(&metadata, option) { + detach_option(&metadata, option, state).await + } else { + match capability_status(&metadata, option, enabled, state).await { + CapabilityStatus::Supported => { + reconcile_option(&metadata, option, state).await + } + CapabilityStatus::Unsupported(_) => { + detach_option(&metadata, option, state).await + } + CapabilityStatus::Indeterminate(_) => Ok(()), + } + }; + if let Err(error) = result { + if option == SyncedOption::GameOptions { + tracing::warn!( + "Game-settings sync changed, but {} could not be reconciled yet: {error}", + metadata.instance.id + ); + continue; + } + return Err(error); + } + } + + get_global_options_with_state(state).await +} + +async fn set_global_option_enabled( + option: SyncedOption, + enabled: bool, + state: &State, +) -> crate::Result<()> { + set_global_option_enabled_in_pool(option, enabled, &state.pool).await +} + +async fn set_global_option_enabled_in_pool( + option: SyncedOption, + enabled: bool, + pool: &SqlitePool, +) -> crate::Result<()> { + let option_name = option.as_str(); + let mut transaction = pool.begin().await?; + sqlx::query( + " + INSERT INTO sync_feature_settings + (feature, globally_enabled, new_instance_default) + VALUES (?, ?, ?) + ON CONFLICT(feature) DO UPDATE SET + globally_enabled = excluded.globally_enabled, + new_instance_default = excluded.new_instance_default + ", + ) + .bind(option_name) + .bind(enabled) + .bind(enabled) + .execute(&mut *transaction) + .await?; + // Upsert every current instance instead of assuming an older migration or + // import populated the complete instance/feature matrix. An UPDATE alone + // silently leaves instances without a preference row disabled. + sqlx::query( + "INSERT INTO instance_sync_preferences (instance_id, feature, enabled) + SELECT id, ?, ? FROM instances + WHERE 1 + ON CONFLICT(instance_id, feature) DO UPDATE SET enabled = excluded.enabled", + ) + .bind(option_name) + .bind(enabled) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) +} +async fn enable_global_option_from_base( + option: SyncedOption, + base_instance_id: &str, + state: &State, +) -> crate::Result { + let source = crate::state::get_instance(base_instance_id, &state.pool) + .await? + .ok_or_else(|| { + ErrorKind::InputError("Unknown sync source instance.".to_string()) + })?; + match capability_status(&source, option, true, state).await { + CapabilityStatus::Supported => {} + CapabilityStatus::Unsupported(reason) + | CapabilityStatus::Indeterminate(reason) => { + return Err(ErrorKind::InputError(reason).into()); + } + } + if sync_files_are_protected(&source) + || (!option_can_apply_while_running(option) + && instance_is_running(&source, state).await?) + { + return queue_source(&source, option, state).await; + } + + if matches!( + option, + SyncedOption::ResourcePacks | SyncedOption::DataPacks + ) { + seed_from_instance(&source, option, state).await?; + complete_pending_source(base_instance_id, option, state).await?; + instance_rows::set_instance_sync_preference( + base_instance_id, + option, + true, + &state.pool, + ) + .await?; + set_global_option_enabled(option, true, state).await?; + for metadata in crate::state::list_instances(&state.pool).await? { + if sync_files_are_protected(&metadata) { + continue; + } + if instance_option_enabled(&metadata, option) { + synced_packs::reconcile(&metadata, option, state).await?; + } + } + return get_global_options_with_state(state).await; + } + + let instances = crate::state::list_instances(&state.pool).await?; + for metadata in &instances { + if metadata.instance.id != base_instance_id + && pending::contains(&metadata.instance.id, option, state).await? + { + continue; + } + if !instance_option_enabled(metadata, option) { + continue; + } + let installing = sync_files_are_protected(metadata); + if installing + || (!option_can_apply_while_running(option) + && instance_is_running(metadata, state).await?) + { + defer_option(metadata, option, state).await?; + if option == SyncedOption::CommandHistory && !installing { + detach_option(metadata, option, state).await?; + } + continue; + } + detach_option(metadata, option, state).await?; + } + if !instance_option_enabled(&source, option) { + detach_option(&source, option, state).await?; + } + + seed_from_instance(&source, option, state).await?; + complete_pending_source(base_instance_id, option, state).await?; + instance_rows::set_instance_sync_preference( + base_instance_id, + option, + true, + &state.pool, + ) + .await?; + set_global_option_enabled(option, true, state).await?; + + for metadata in crate::state::list_instances(&state.pool).await? { + if pending::contains(&metadata.instance.id, option, state).await? { + continue; + } + if sync_files_are_protected(&metadata) + || (!option_can_apply_while_running(option) + && instance_is_running(&metadata, state).await?) + { + continue; + } + if !instance_option_enabled(&metadata, option) { + detach_option(&metadata, option, state).await?; + continue; + } + match capability_status(&metadata, option, true, state).await { + CapabilityStatus::Supported => { + ensure_option(&metadata, option, state).await? + } + CapabilityStatus::Unsupported(_) => { + detach_option(&metadata, option, state).await? + } + CapabilityStatus::Indeterminate(_) => {} + } + } + + get_global_options_with_state(state).await +} + +async fn complete_pending_source( + instance_id: &str, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + pending::remove( + &PendingChange { + instance_id: instance_id.to_string(), + option, + action: PendingAction::SelectSource, + }, + state, + ) + .await +} + +async fn queue_source( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result { + pending::save( + PendingChange { + instance_id: metadata.instance.id.clone(), + option, + action: PendingAction::SelectSource, + }, + state, + ) + .await?; + instance_rows::set_instance_sync_preference( + &metadata.instance.id, + option, + true, + &state.pool, + ) + .await?; + set_global_option_enabled(option, true, state).await?; + get_global_options_with_state(state).await +} + +/// Pending checkpoints apply shared data without capturing the old local file. +async fn defer_option( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + let variants: &[&str] = match option { + SyncedOption::CommandHistory | SyncedOption::MultiplayerServers => { + &["default"] + } + SyncedOption::CreativeHotbars => &["legacy", "components"], + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + return synced_packs::detach(metadata, option, state).await; + } + SyncedOption::GameOptions | SyncedOption::Screenshots => return Ok(()), + }; + for variant in variants { + begin_checkpoint( + &metadata.instance.id, + option, + variant, + "", + None, + 0, + state, + ) + .await?; + } + Ok(()) +} + +pub async fn set_instance_option( + instance_id: &str, + option: SyncedOption, + enabled: bool, + resolution: Option, +) -> crate::Result { + if !option.is_available() { + return Err(ErrorKind::InputError( + "Data pack syncing is currently disabled.".to_string(), + ) + .into()); + } + 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()))?; + let previous_enabled = instance_option_enabled(&metadata, option); + // A global toggle can update this preference before the local projection + // is created. Re-run the enable path instead of returning early solely + // because the preference already says enabled. + // Disabling is intentionally idempotent: the persisted preference may + // already be false while an old projection or pending operation still + // needs to be detached. + let global = get_global_options_with_state(&state).await?; + let can_reconcile = !sync_files_are_protected(&metadata) + && (option_can_apply_while_running(option) + || !instance_is_running(&metadata, &state).await?); + if enabled { + let eligibility = + capability(&metadata, option, global.get(option), &state).await; + if !eligibility.supported { + return Err(ErrorKind::InputError( + eligibility.disabled_reason.unwrap_or_default(), + ) + .into()); + } + let action = + instance_option_join_action(&metadata, option, &state).await?; + if action == SyncedOptionJoinAction::RequiresResolution + && resolution.is_none() + { + return Err(ErrorKind::InputError( + "Choose whether to use the synced hotbars or this instance's hotbars." + .to_string(), + ) + .into()); + } + if can_reconcile { + pending::cancel(option, Some(instance_id), &state).await?; + prepare_instance_join(&metadata, option, resolution, &state) + .await?; + } else { + pending::save( + PendingChange { + instance_id: instance_id.to_string(), + option, + action: PendingAction::Join { resolution }, + }, + &state, + ) + .await?; + } + } else { + pending::cancel(option, Some(instance_id), &state).await?; + } + + instance_rows::set_instance_sync_preference( + instance_id, + option, + enabled, + &state.pool, + ) + .await?; + // The projection code must see the preference that was just persisted. + // In particular, game-settings reconciliation checks + // `metadata.synced_options` before applying anything, so using the stale + // pre-write metadata would turn a successful enable into a no-op. + let updated_metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + if can_reconcile { + let result = if enabled { + ensure_option(&updated_metadata, option, &state).await + } else { + detach_option(&updated_metadata, option, &state).await + }; + if let Err(error) = result { + if option == SyncedOption::GameOptions + && let Err(rollback_error) = + instance_rows::set_instance_sync_preference( + instance_id, + option, + previous_enabled, + &state.pool, + ) + .await + { + tracing::error!( + "Failed to roll back the game-settings sync preference for {instance_id}: {rollback_error}" + ); + } + return Err(error); + } + } else if !enabled + && matches!( + option, + SyncedOption::ResourcePacks | SyncedOption::DataPacks + ) + { + detach_option(&updated_metadata, option, &state).await?; + } + + Ok(updated_metadata) +} + +pub async fn get_instance_option_join_preview( + instance_id: &str, + option: SyncedOption, +) -> 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()))?; + let global = get_global_options_with_state(&state).await?; + let eligibility = + capability(&metadata, option, global.get(option), &state).await; + if !eligibility.supported { + return Err(ErrorKind::InputError( + eligibility.disabled_reason.unwrap_or_default(), + ) + .into()); + } + + Ok(SyncedOptionJoinPreview { + action: instance_option_join_action(&metadata, option, &state).await?, + }) +} + +async fn instance_option_join_action( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result { + if !canonical_exists(option, state).await? { + return Ok(SyncedOptionJoinAction::SeedShared); + } + Ok(match option { + SyncedOption::GameOptions => SyncedOptionJoinAction::Attach, + SyncedOption::CommandHistory | SyncedOption::MultiplayerServers => { + SyncedOptionJoinAction::Merge + } + SyncedOption::CreativeHotbars => { + if sync_files_are_protected(metadata) + || instance_is_running(metadata, state).await? + || instance_hotbars_differ_from_synced(metadata, state).await? + { + SyncedOptionJoinAction::RequiresResolution + } else { + SyncedOptionJoinAction::Attach + } + } + SyncedOption::Screenshots + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks => SyncedOptionJoinAction::Attach, + }) +} + +async fn prepare_instance_join( + metadata: &InstanceMetadata, + option: SyncedOption, + resolution: Option, + state: &State, +) -> crate::Result<()> { + let action = instance_option_join_action(metadata, option, state).await?; + backup_instance_option_file(metadata, option, state).await?; + match action { + SyncedOptionJoinAction::SeedShared => { + seed_from_instance(metadata, option, state).await?; + } + SyncedOptionJoinAction::Attach => {} + SyncedOptionJoinAction::Merge => match option { + SyncedOption::CommandHistory => { + merge_command_history_from_instance(metadata, state).await?; + } + SyncedOption::MultiplayerServers => { + synced_servers::merge_servers_from_instance(metadata, state) + .await?; + } + _ => unreachable!(), + }, + SyncedOptionJoinAction::RequiresResolution => { + if resolution == Some(SyncedOptionJoinResolution::UseInstance) { + backup_shared_hotbars(state).await?; + seed_from_instance(metadata, option, state).await?; + } + } + } + defer_option(metadata, option, state).await +} + +async fn apply_pending_changes(state: &State) -> crate::Result<()> { + for change in pending::read(state).await? { + if !pending::read(state).await?.contains(&change) { + continue; + } + let Some(metadata) = + crate::state::get_instance(&change.instance_id, &state.pool) + .await? + else { + pending::remove(&change, state).await?; + continue; + }; + let global = get_global_options_with_state(state).await?; + if !global.get(change.option) + || !instance_option_enabled(&metadata, change.option) + { + pending::remove(&change, state).await?; + continue; + } + if sync_files_are_protected(&metadata) + || (!option_can_apply_while_running(change.option) + && instance_is_running(&metadata, state).await?) + { + continue; + } + let result = match change.action { + PendingAction::SelectSource => set_global_option_with_state( + change.option, + true, + Some(&change.instance_id), + state, + ) + .await + .map(|_| ()), + PendingAction::Join { resolution } => { + if !matches!( + capability_status(&metadata, change.option, true, state) + .await, + CapabilityStatus::Supported + ) { + continue; + } + match prepare_instance_join( + &metadata, + change.option, + resolution, + state, + ) + .await + { + Ok(()) => { + pending::remove(&change, state).await?; + ensure_option(&metadata, change.option, state).await + } + Err(error) => Err(error), + } + } + }; + if let Err(error) = result { + tracing::warn!( + "Could not apply pending {:?} sync change for {}: {error}", + change.option, + change.instance_id, + ); + } + } + Ok(()) +} + +pub async fn reconcile_all() -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + create_synced_directories(&state).await?; + apply_pending_changes(&state).await?; + let instances = crate::state::list_instances(&state.pool).await?; + for metadata in instances { + if let Err(error) = + reconcile_instance_with_state(&metadata, &state, false).await + { + tracing::warn!( + "Failed to reconcile synced options for {}: {error}", + metadata.instance.id + ); + } + } + Ok(()) +} + +pub(crate) async fn monitor_persisted_processes() -> crate::Result<()> { + let state = State::get().await?; + let instance_ids = + sqlx::query_scalar::<_, String>( + "SELECT DISTINCT instance_id FROM processes", + ) + .fetch_all(&state.pool) + .await?; + for instance_id in instance_ids { + tokio::spawn(async move { + loop { + let Ok(state) = State::get().await else { + break; + }; + let Ok(Some(metadata)) = + crate::state::get_instance(&instance_id, &state.pool).await + else { + break; + }; + match instance_is_running(&metadata, &state).await { + Ok(true) => { + tokio::time::sleep(std::time::Duration::from_secs(5)) + .await; + } + Ok(false) => { + if let Err(error) = + reconcile_instance(&instance_id).await + { + tracing::warn!( + "Failed to reconcile synced options after closing {instance_id}: {error}" + ); + } + break; + } + Err(error) => { + tracing::warn!( + "Failed to inspect the persisted process for {instance_id}: {error}" + ); + tokio::time::sleep(std::time::Duration::from_secs(5)) + .await; + } + } + } + }); + } + Ok(()) +} + +/// Keeps reconciliation state on the heap so callers do not inherit its size. +pub fn reconcile_instance( + instance_id: &str, +) -> impl Future> + Send + '_ { + Box::pin(reconcile_instance_inner(instance_id)) +} + +async fn reconcile_instance_inner(instance_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + apply_pending_changes(&state).await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + reconcile_instance_with_state(&metadata, &state, false).await +} + +pub(crate) async fn reconcile_instance_after_pack_update_with_state( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + 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()))?; + reconcile_instance_with_state(&metadata, state, true).await +} + +pub(crate) async fn prepare_instance_update( + instance_id: &str, +) -> 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 let Err(error) = + synced_packs::prepare_instance_update(&metadata, &state).await + { + tracing::warn!( + "The modpack update will continue, but resource-pack selection could not be prepared: {error}" + ); + } + for option in [ + SyncedOption::GameOptions, + SyncedOption::CommandHistory, + SyncedOption::CreativeHotbars, + SyncedOption::MultiplayerServers, + ] { + if option == SyncedOption::GameOptions { + if let Err(error) = + super::game_options::prepare_instance_update_with_state( + &metadata, &state, + ) + .await + { + tracing::warn!( + "The modpack update will continue, but options.txt could not be prepared for game-settings sync: {error}" + ); + } + continue; + } + if instance_option_enabled(&metadata, option) { + detach_option(&metadata, option, &state).await?; + } + } + Ok(()) +} + +async fn reconcile_instance_with_state( + metadata: &InstanceMetadata, + state: &State, + allow_protected: bool, +) -> crate::Result<()> { + if !allow_protected && sync_files_are_protected(metadata) { + return Ok(()); + } + let running = instance_is_running(metadata, state).await?; + let global = get_global_options_with_state(state).await?; + for option in SyncedOption::ALL { + if pending::contains(&metadata.instance.id, option, state).await? { + continue; + } + if running && !option_can_apply_while_running(option) { + continue; + } + if !instance_option_enabled(metadata, option) { + detach_option(metadata, option, state).await?; + continue; + } + match capability_status(metadata, option, global.get(option), state) + .await + { + CapabilityStatus::Supported => { + if option == SyncedOption::GameOptions { + continue; + } + let result = reconcile_option(metadata, option, state).await; + if let Err(error) = result { + if option == SyncedOption::GameOptions { + tracing::warn!( + "Game settings could not be reconciled for {}, continuing with the remaining synced options: {error}", + metadata.instance.id + ); + } else { + return Err(error); + } + } + } + CapabilityStatus::Unsupported(_) => { + detach_option(metadata, option, state).await? + } + CapabilityStatus::Indeterminate(_) => {} + } + } + Ok(()) +} + +async fn reconcile_option( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + match option { + SyncedOption::GameOptions => { + super::game_options::apply_instance_with_state(metadata, state) + .await + } + SyncedOption::CommandHistory => { + reconcile_command_history(metadata, state).await + } + SyncedOption::CreativeHotbars => { + reconcile_hotbar(metadata, state).await + } + SyncedOption::MultiplayerServers => { + synced_servers::reconcile_servers(metadata, state).await + } + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + synced_packs::reconcile(metadata, option, state).await + } + SyncedOption::Screenshots => Ok(()), + } +} + +pub async fn reconcile_changed_file( + instance_id: &str, + file_name: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let _guard = state.lock_synced_options().await; + apply_pending_changes(&state).await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?; + if sync_files_are_protected(&metadata) { + return Ok(()); + } + match file_name { + "options.txt" => { + let participates = option_participates( + &metadata, + SyncedOption::GameOptions, + &state, + ) + .await?; + if participates { + super::game_options::capture_instance_file_change( + &metadata, &state, + ) + .await?; + } + let resource_packs_participate = option_participates( + &metadata, + SyncedOption::ResourcePacks, + &state, + ) + .await?; + if resource_packs_participate { + synced_packs::capture_resource_pack_selection_change( + &metadata, &state, + ) + .await?; + } + Ok(()) + } + COMMAND_HISTORY_FILE => { + reconcile_command_history(&metadata, &state).await + } + HOTBAR_FILE => reconcile_hotbar(&metadata, &state).await, + "servers.dat" => { + synced_servers::reconcile_servers(&metadata, &state).await + } + "resourcepacks" => { + let option = SyncedOption::ResourcePacks; + if !option_participates(&metadata, option, &state).await? { + return Ok(()); + } + synced_packs::seed_from_instance(&metadata, option, &state).await?; + for target in crate::state::list_instances(&state.pool).await? { + if !sync_files_are_protected(&target) + && option_participates(&target, option, &state).await? + { + synced_packs::reconcile(&target, option, &state).await?; + } + } + Ok(()) + } + "datapacks" => { + let option = SyncedOption::DataPacks; + if !option_participates(&metadata, option, &state).await? { + return Ok(()); + } + synced_packs::seed_from_instance(&metadata, option, &state).await?; + for target in crate::state::list_instances(&state.pool).await? { + if !sync_files_are_protected(&target) + && option_participates(&target, option, &state).await? + { + synced_packs::reconcile(&target, option, &state).await?; + } + } + Ok(()) + } + _ => Ok(()), + } +} + +pub(crate) async fn remove_generated_instance_files( + instance_id: &str, + state: &State, +) -> crate::Result<()> { + let instance_id = safe_instance_id(instance_id); + for path in [ + synced_options_path(state) + .join("hotbars/generated/legacy") + .join(&instance_id), + synced_options_path(state) + .join("hotbars/generated/components") + .join(&instance_id), + synced_options_path(state) + .join("servers/generated") + .join(&instance_id), + synced_options_path(state) + .join("backups") + .join(&instance_id), + ] { + if path.exists() { + io::remove_dir_all(path).await?; + } + } + Ok(()) +} + +pub(super) async fn create_synced_directories( + state: &State, +) -> crate::Result<()> { + for path in [ + synced_options_path(state), + synced_options_path(state).join("hotbars/generated/legacy"), + synced_options_path(state).join("hotbars/generated/components"), + synced_options_path(state).join("servers/generated"), + ] { + io::create_dir_all(path).await?; + } + Ok(()) +} + +async fn backup_instance_option_file( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + let directory = instance_game_dir(metadata, state)?; + let path = match option { + SyncedOption::GameOptions => directory.join("options.txt"), + SyncedOption::CommandHistory => directory.join(COMMAND_HISTORY_FILE), + SyncedOption::CreativeHotbars => directory.join(HOTBAR_FILE), + SyncedOption::MultiplayerServers => directory.join("servers.dat"), + SyncedOption::Screenshots + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks => return Ok(()), + }; + if !path.exists() { + return Ok(()); + } + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) + else { + return Ok(()); + }; + backup_bytes( + &metadata.instance.id, + file_name, + &io::read(&path).await?, + state, + ) + .await +} + +pub(super) async fn backup_bytes( + owner: &str, + file_name: &str, + contents: &[u8], + state: &State, +) -> crate::Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let directory = synced_options_path(state) + .join("backups") + .join(safe_instance_id(owner)) + .join(timestamp.to_string()); + io::create_dir_all(&directory).await?; + io::write(directory.join(file_name), contents).await?; + Ok(()) +} + +pub(super) async fn seed_from_instance( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + create_synced_directories(state).await?; + let instance_dir = instance_game_dir(metadata, state)?; + match option { + SyncedOption::GameOptions => { + super::game_options::initialize_from_source_instance( + metadata, state, + ) + .await?; + } + SyncedOption::CommandHistory => { + let path = instance_dir.join(COMMAND_HISTORY_FILE); + let contents = if path.exists() { + String::from_utf8_lossy(&io::read(path).await?).into_owned() + } else { + String::new() + }; + io::write( + command_history_path(state), + normalize_command_history(&contents), + ) + .await?; + } + SyncedOption::CreativeHotbars => { + let path = instance_dir.join(HOTBAR_FILE); + let family = hotbar_family(metadata, state).await?; + let root = if path.exists() { + read_nbt_file(&path).await? + } else { + empty_hotbar_root() + }; + let previous_state = read_hotbar_state(state).await?; + let mut sync_state = HotbarState { + schema_version: HOTBAR_SCHEMA_VERSION, + revision: previous_state.revision, + nbt: NbtCompound::new(), + }; + let merge_base = empty_hotbar_root(); + if merge_hotbar_family( + &mut sync_state.nbt, + family, + &merge_base, + &root, + ) { + increment_hotbar_revision(&mut sync_state); + } + write_hotbar_state(state, &sync_state).await?; + regenerate_hotbars(state).await?; + } + SyncedOption::MultiplayerServers => { + synced_servers::seed_servers(metadata, state).await?; + } + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + synced_packs::seed_from_instance(metadata, option, state).await?; + } + SyncedOption::Screenshots => {} + } + Ok(()) +} + +async fn ensure_option( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + match option { + SyncedOption::GameOptions => { + super::game_options::apply_instance_with_state(metadata, state) + .await + } + SyncedOption::CommandHistory => { + ensure_command_history(metadata, state).await + } + SyncedOption::CreativeHotbars => ensure_hotbar(metadata, state).await, + SyncedOption::MultiplayerServers => { + synced_servers::ensure_servers(metadata, state).await + } + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + synced_packs::reconcile(metadata, option, state).await + } + SyncedOption::Screenshots => Ok(()), + } +} + +async fn detach_option( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result<()> { + let instance_dir = instance_game_dir(metadata, state)?; + match option { + SyncedOption::GameOptions => { + super::game_options::detach_instance(metadata, state).await + } + SyncedOption::CommandHistory => { + detach_link( + &command_history_path(state), + &instance_dir.join(COMMAND_HISTORY_FILE), + ) + .await + } + SyncedOption::CreativeHotbars => { + let target = instance_dir.join(HOTBAR_FILE); + let family = hotbar_family(metadata, state).await.ok(); + let source = family + .map(|family| { + generated_hotbar_path(state, family, &metadata.instance.id) + }) + .unwrap_or_else(|| target.clone()); + detach_link(&source, &target).await + } + SyncedOption::MultiplayerServers => { + synced_servers::detach_servers(metadata, state).await + } + SyncedOption::ResourcePacks | SyncedOption::DataPacks => { + synced_packs::detach(metadata, option, state).await + } + SyncedOption::Screenshots => Ok(()), + } +} + +async fn canonical_exists( + option: SyncedOption, + state: &State, +) -> crate::Result { + Ok(match option { + SyncedOption::GameOptions => { + super::game_options::canonical_exists(state).await? + } + SyncedOption::CommandHistory => command_history_path(state).exists(), + SyncedOption::CreativeHotbars => hotbar_state_exists(state).await?, + SyncedOption::MultiplayerServers => { + synced_servers::canonical_exists(state).await? + } + SyncedOption::Screenshots + | SyncedOption::ResourcePacks + | SyncedOption::DataPacks => true, + }) +} + +pub(super) async fn option_effective( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result { + if sync_files_are_protected(metadata) + || instance_is_running(metadata, state).await? + { + return Ok(false); + } + option_participates(metadata, option, state).await +} + +async fn option_participates( + metadata: &InstanceMetadata, + option: SyncedOption, + state: &State, +) -> crate::Result { + if pending::contains(&metadata.instance.id, option, state).await? { + return Ok(false); + } + let global = get_global_options_with_state(state).await?; + Ok(instance_option_enabled(metadata, option) + && capability(metadata, option, global.get(option), state) + .await + .supported) +} + +fn is_linked_server_project(link: &InstanceLink) -> bool { + matches!( + link, + InstanceLink::ServerProject { .. } + | InstanceLink::ServerProjectModpack { .. } + ) +} + +fn option_from_str(value: &str) -> Option { + match value { + "game_options" => Some(SyncedOption::GameOptions), + "command_history" => Some(SyncedOption::CommandHistory), + "multiplayer_servers" => Some(SyncedOption::MultiplayerServers), + "creative_hotbars" => Some(SyncedOption::CreativeHotbars), + "screenshots" => Some(SyncedOption::Screenshots), + "resource_packs" => Some(SyncedOption::ResourcePacks), + "data_packs" => Some(SyncedOption::DataPacks), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + #[tokio::test] + async fn global_toggle_upserts_every_instance_preference() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::raw_sql( + r#" + CREATE TABLE instances (id TEXT PRIMARY KEY NOT NULL); + CREATE TABLE sync_feature_settings ( + feature TEXT PRIMARY KEY NOT NULL, + globally_enabled INTEGER NOT NULL, + new_instance_default INTEGER NOT NULL + ); + CREATE TABLE instance_sync_preferences ( + instance_id TEXT NOT NULL, + feature TEXT NOT NULL, + enabled INTEGER NOT NULL, + PRIMARY KEY (instance_id, feature) + ); + INSERT INTO instances (id) VALUES ('one'), ('two'); + INSERT INTO sync_feature_settings + (feature, globally_enabled, new_instance_default) + VALUES ('multiplayer_servers', 0, 0); + INSERT INTO instance_sync_preferences + (instance_id, feature, enabled) + VALUES ('one', 'multiplayer_servers', 0); + "#, + ) + .execute(&pool) + .await + .unwrap(); + + set_global_option_enabled_in_pool( + SyncedOption::MultiplayerServers, + true, + &pool, + ) + .await + .unwrap(); + + let global: (bool, bool) = sqlx::query_as( + "SELECT globally_enabled, new_instance_default + FROM sync_feature_settings WHERE feature = 'multiplayer_servers'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(global, (true, true)); + + let preferences: Vec<(String, bool)> = sqlx::query_as( + "SELECT instance_id, enabled FROM instance_sync_preferences + WHERE feature = 'multiplayer_servers' ORDER BY instance_id", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + preferences, + vec![("one".to_string(), true), ("two".to_string(), true)] + ); + + set_global_option_enabled_in_pool( + SyncedOption::MultiplayerServers, + false, + &pool, + ) + .await + .unwrap(); + let enabled_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM instance_sync_preferences + WHERE feature = 'multiplayer_servers' AND enabled = 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(enabled_count, 0); + } +} diff --git a/packages/app-lib/src/api/instance/synced_packs_axolotl.rs b/packages/app-lib/src/api/instance/synced_packs_axolotl.rs new file mode 100644 index 0000000..04415d3 --- /dev/null +++ b/packages/app-lib/src/api/instance/synced_packs_axolotl.rs @@ -0,0 +1,1135 @@ +//! Axolotl's shared resource/data-pack projection. +//! +//! The upstream implementation stores a launcher-wide JSON library and decorates +//! the upstream `ContentItem` with sync-only fields. Axolotl keeps content +//! ownership in SQLite, so this adapter stores only the shared catalog here and +//! lets the normal instance watcher materialize ownership in each content set. + +use crate::api::instance::synced_servers::DesyncServerMode; +use crate::state::{ + ContentItem, ContentItemOwner, ContentItemProject, ContentItemRollback, + ContentItemVersion, ContentOwnershipKind, ContentProvider, + ContentProviderRef, ContentRequirement, ContentSourceKind, ProjectType, + State, +}; +use bytes::Bytes; +use chrono::Utc; +use serde::Serialize; +use sqlx::Row; +use std::collections::{BTreeSet, HashSet}; +use std::path::{Component, Path, PathBuf}; + +#[derive(Clone, Debug, Serialize)] +pub struct PackSyncTarget { + pub instance_id: String, + pub name: String, + pub game_version: String, + pub compatible: bool, + pub participating: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PackSyncPreview { + pub pack: ContentItem, + pub instances: Vec, +} + +#[derive(Clone, Debug, sqlx::FromRow)] +struct PackRow { + id: String, + project_type: String, + file_name: String, + sha1: String, + size: i64, + game_versions_json: String, + enabled: i64, +} + +fn parse_type(value: &str) -> crate::Result { + match value { + "resourcepack" => Ok(ProjectType::ResourcePack), + "datapack" => Ok(ProjectType::DataPack), + other => Err(crate::ErrorKind::InputError(format!( + "Unsupported synced pack type: {other}" + )) + .into()), + } +} + +fn validate_type(project_type: ProjectType) -> crate::Result<()> { + if matches!( + project_type, + ProjectType::ResourcePack | ProjectType::DataPack + ) { + Ok(()) + } else { + Err(crate::ErrorKind::InputError( + "Only resource packs and data packs can be synced.".to_string(), + ) + .into()) + } +} + +async fn globally_enabled(project_type: ProjectType) -> crate::Result { + let option = match project_type { + ProjectType::ResourcePack => crate::state::SyncedOption::ResourcePacks, + ProjectType::DataPack => crate::state::SyncedOption::DataPacks, + _ => return Ok(false), + }; + Ok(crate::api::instance::synced_options::get_global_options() + .await? + .get(option)) +} + +fn cache_dir(state: &State) -> PathBuf { + state.directories.synced_options_dir().join("packs/files") +} + +fn cache_path(state: &State, sha1: &str) -> PathBuf { + cache_dir(state).join(sha1) +} + +fn content_root( + state: &State, + metadata: &crate::state::InstanceMetadata, +) -> crate::Result { + crate::state::instances::commands::instance_content_root( + &state.directories, + &metadata.instance, + ) +} + +fn logical_path( + project_type: ProjectType, + file_name: &str, + enabled: bool, +) -> String { + format!( + "{}/{}{}", + project_type.get_folder(), + file_name.trim_end_matches(".disabled"), + if enabled { "" } else { ".disabled" } + ) +} + +fn game_versions(row: &PackRow) -> Vec { + serde_json::from_str(&row.game_versions_json).unwrap_or_default() +} + +fn resource_pack_option_entry( + metadata: &crate::state::InstanceMetadata, + entry: &str, +) -> String { + let legacy = metadata + .applied_content_set + .game_version + .strip_prefix("1.") + .and_then(|version| version.split('.').next()?.parse::().ok()) + .is_some_and(|minor| minor < 13); + if legacy { + entry.strip_prefix("file/").unwrap_or(entry).to_string() + } else { + entry.to_string() + } +} + +fn version_compatible( + row: &PackRow, + metadata: &crate::state::InstanceMetadata, +) -> bool { + let versions = game_versions(row); + versions.is_empty() + || versions.iter().any(|version| { + version == &metadata.applied_content_set.game_version + }) +} + +fn safe_relative_path(path: &str) -> Option { + let path = Path::new(path); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir + | Component::RootDir + | Component::Prefix(_) + ) + }) + { + return None; + } + Some(path.to_path_buf()) +} + +async fn tracked_materialized_path( + state: &State, + pack_id: &str, + instance_id: &str, +) -> crate::Result> { + Ok(sqlx::query_scalar::<_, Option>( + "SELECT materialized_path FROM synced_pack_instances + WHERE pack_id = ? AND instance_id = ?", + ) + .bind(pack_id) + .bind(instance_id) + .fetch_optional(&state.pool) + .await? + .flatten()) +} + +fn normalize_tracked_path( + metadata: &crate::state::InstanceMetadata, + tracked: &str, +) -> Option { + let normalized = tracked.replace('\\', "/"); + let instance_path = metadata.instance.path.replace('\\', "/"); + let relative = normalized + .strip_prefix(&format!("{instance_path}/")) + .unwrap_or(&normalized); + safe_relative_path(relative) + .map(|path| path.to_string_lossy().replace('\\', "/")) +} + +async fn file_matches_sha1(path: &Path, expected: &str) -> bool { + match tokio::fs::read(path).await { + Ok(bytes) => crate::util::fetch::sha1_async(Bytes::from(bytes)) + .await + .is_ok_and(|sha1| sha1 == expected), + Err(_) => false, + } +} + +async fn cleanup_materialization( + state: &State, + row: &PackRow, + metadata: &crate::state::InstanceMetadata, +) -> crate::Result<()> { + let Some(tracked) = + tracked_materialized_path(state, &row.id, &metadata.instance.id) + .await? + else { + return Ok(()); + }; + let Some(relative_path) = normalize_tracked_path(metadata, &tracked) else { + tracing::warn!( + "Ignoring unsafe synced-pack materialized path for {}: {tracked}", + metadata.instance.id + ); + return Ok(()); + }; + let absolute = content_root(state, metadata)?.join(&relative_path); + if file_matches_sha1(&absolute, &row.sha1).await { + match tokio::fs::remove_file(&absolute).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + + let mut transaction = state.pool.begin().await?; + sqlx::query( + "DELETE FROM instance_pack_members + WHERE member_key = ? + AND content_set_id IN ( + SELECT id FROM instance_content_sets WHERE instance_id = ? + )", + ) + .bind(format!("shared:{}", row.id)) + .bind(&metadata.instance.id) + .execute(&mut *transaction) + .await?; + sqlx::query( + "DELETE FROM instance_content_entries + WHERE instance_id = ? + AND source_kind = 'shared_instance' + AND file_id IN ( + SELECT id FROM instance_files + WHERE instance_id = ? AND relative_path = ? + )", + ) + .bind(&metadata.instance.id) + .bind(&metadata.instance.id) + .bind(&relative_path) + .execute(&mut *transaction) + .await?; + sqlx::query( + "DELETE FROM instance_files + WHERE instance_id = ? AND relative_path = ? + AND NOT EXISTS ( + SELECT 1 FROM instance_content_entries + WHERE file_id = instance_files.id + )", + ) + .bind(&metadata.instance.id) + .bind(&relative_path) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) +} + +async fn record_materialization( + state: &State, + pack_id: &str, + instance_id: &str, + excluded: bool, + materialized_path: Option<&str>, +) -> crate::Result<()> { + sqlx::query( + "INSERT INTO synced_pack_instances + (pack_id, instance_id, excluded, materialized_path, modified_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(pack_id, instance_id) DO UPDATE SET + excluded=excluded.excluded, + materialized_path=excluded.materialized_path, + modified_at=excluded.modified_at", + ) + .bind(pack_id) + .bind(instance_id) + .bind(i64::from(excluded)) + .bind(materialized_path) + .bind(Utc::now().timestamp()) + .execute(&state.pool) + .await?; + Ok(()) +} + +fn row_item(row: &PackRow) -> crate::Result { + let project_type = parse_type(&row.project_type)?; + let file_name = row.file_name.trim_end_matches(".disabled").to_string(); + Ok(ContentItem { + file_name: file_name.clone(), + file_path: logical_path(project_type, &file_name, row.enabled != 0), + id: row.id.clone(), + size: row.size.max(0) as u64, + enabled: row.enabled != 0, + project_type, + project: None::, + version: None::, + owner: None::, + update: None, + date_added: None, + provider_refs: Vec::::new(), + origin_provider: None::, + rollback: None::, + environment: None, + source_kind: Some(ContentSourceKind::SharedInstance), + external: true, + loader: None, + }) +} + +async fn load_row(id: &str, state: &State) -> crate::Result { + sqlx::query_as::<_, PackRow>( + "SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled + FROM synced_pack_catalog WHERE id = ?", + ) + .bind(id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| crate::ErrorKind::InputError("Unknown synced pack".to_string()).into()) +} + +async fn write_cache( + state: &State, + bytes: &Bytes, + sha1: &str, +) -> crate::Result<()> { + tokio::fs::create_dir_all(cache_dir(state)).await?; + let path = cache_path(state, sha1); + if !tokio::fs::try_exists(&path).await? { + tokio::fs::write(path, bytes).await?; + } + Ok(()) +} + +async fn materialize( + state: &State, + row: &PackRow, + instance_id: &str, + excluded: bool, +) -> crate::Result<()> { + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let project_type = parse_type(&row.project_type)?; + let root = content_root(state, &metadata)?; + let relative_path = + logical_path(project_type, &row.file_name, row.enabled != 0); + let destination = root.join(&relative_path); + if excluded + || !globally_enabled(project_type).await? + || !metadata.synced_options_for(project_type) + || !version_compatible(row, &metadata) + { + cleanup_materialization(state, row, &metadata).await?; + record_materialization(state, &row.id, instance_id, excluded, None) + .await?; + return Ok(()); + } + + let tracked = + tracked_materialized_path(state, &row.id, instance_id).await?; + let owns_destination = tracked + .as_deref() + .and_then(|path| normalize_tracked_path(&metadata, path)) + .is_some_and(|path| path == relative_path); + if tokio::fs::try_exists(&destination).await? + && !owns_destination + && !file_matches_sha1(&destination, &row.sha1).await + { + return Err(crate::ErrorKind::InputError(format!( + "Cannot sync {} because {} already contains a different local file.", + row.file_name, + destination.display() + )) + .into()); + } + if tracked.as_deref().is_some_and(|path| { + normalize_tracked_path(&metadata, path) + .is_some_and(|path| path != relative_path) + }) { + cleanup_materialization(state, row, &metadata).await?; + } + if let Some(parent) = destination.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let bytes = tokio::fs::read(cache_path(state, &row.sha1)).await?; + let destination_matches = tokio::fs::read(&destination) + .await + .is_ok_and(|existing| existing == bytes); + if !destination_matches { + tokio::fs::write(&destination, &bytes).await?; + } + let file = crate::state::instances::adapters::sqlite::content_rows::upsert_instance_file_from_parts( + crate::state::instances::adapters::sqlite::content_rows::UpsertInstanceFile { + instance_id, + relative_path: &relative_path, + file_name: &row.file_name, + enabled: row.enabled != 0, + sha1: &row.sha1, + size: row.size.max(0) as u64, + missing: false, + local_mod_data: None, + icon_path: None, + }, + &state.pool, + ) + .await?; + let entry = crate::state::instances::adapters::sqlite::content_rows::upsert_content_entry_from_parts( + crate::state::instances::adapters::sqlite::content_rows::UpsertContentEntry { + instance_id, + content_set_id: &metadata.applied_content_set.id, + file_id: Some(&file.id), + project_type, + source_kind: ContentSourceKind::SharedInstance, + ownership_kind: ContentOwnershipKind::PackManaged, + auto_dependency: false, + server_requirement: ContentRequirement::Optional, + client_requirement: ContentRequirement::Optional, + enabled: row.enabled != 0, + }, + &state.pool, + ) + .await?; + sqlx::query( + "INSERT INTO instance_pack_members + (id, content_set_id, content_entry_id, member_key, project_type, + expected_relative_path, required, expected_sha1, expected_size, + materialization_state, override_kind, reconciled, created_at, modified_at) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, 'present', 'none', 1, ?, ?) + ON CONFLICT(content_set_id, member_key) DO UPDATE SET + content_entry_id=excluded.content_entry_id, + expected_sha1=excluded.expected_sha1, expected_size=excluded.expected_size, + materialization_state='present', override_kind='none', + reconciled=1, modified_at=excluded.modified_at", + ) + .bind(format!("pack-member:{}", row.id)) + .bind(&metadata.applied_content_set.id) + .bind(&entry.id) + .bind(format!("shared:{}", row.id)) + .bind(project_type.get_name()) + .bind(&relative_path) + .bind(&row.sha1) + .bind(row.size) + .bind(Utc::now().timestamp()) + .bind(Utc::now().timestamp()) + .execute(&state.pool) + .await?; + record_materialization( + state, + &row.id, + instance_id, + excluded, + Some(&relative_path), + ) + .await?; + Ok(()) +} + +trait SyncedPackInstanceOptions { + fn synced_options_for(&self, project_type: ProjectType) -> bool; +} + +impl SyncedPackInstanceOptions for crate::state::InstanceMetadata { + fn synced_options_for(&self, project_type: ProjectType) -> bool { + match project_type { + ProjectType::ResourcePack => self.synced_options.resource_packs, + ProjectType::DataPack => self.synced_options.data_packs, + _ => false, + } + } +} + +pub async fn list_synced_packs( + project_type: ProjectType, +) -> crate::Result> { + validate_type(project_type)?; + let state = State::get().await?; + let rows = sqlx::query_as::<_, PackRow>( + "SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled + FROM synced_pack_catalog WHERE project_type = ? ORDER BY modified_at DESC", + ) + .bind(project_type.get_name()) + .fetch_all(&state.pool) + .await?; + rows.iter().map(row_item).collect() +} + +pub async fn get_pack_sync_preview( + instance_id: &str, + project_path: &str, +) -> crate::Result { + 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 project_type = project_path + .split('/') + .next() + .and_then(|folder| match folder { + "resourcepacks" => Some(ProjectType::ResourcePack), + "datapacks" => Some(ProjectType::DataPack), + _ => None, + }) + .ok_or_else(|| { + crate::ErrorKind::InputError("Invalid pack path".to_string()) + })?; + validate_type(project_type)?; + let global_enabled = globally_enabled(project_type).await?; + let source = content_root(&state, &metadata)?.join(project_path); + let bytes = Bytes::from(tokio::fs::read(&source).await?); + let sha1 = crate::util::fetch::sha1_async(bytes.clone()).await?; + let row = sqlx::query_as::<_, PackRow>( + "SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled + FROM synced_pack_catalog WHERE sha1 = ?", + ).bind(&sha1).fetch_optional(&state.pool).await?; + let preview_row = row.unwrap_or(PackRow { + id: format!("synced-pack:{sha1}"), + project_type: project_type.get_name().to_string(), + file_name: Path::new(project_path) + .file_name() + .and_then(|x| x.to_str()) + .unwrap_or("pack.zip") + .to_string(), + sha1, + size: bytes.len() as i64, + game_versions_json: "[]".to_string(), + enabled: 1, + }); + let pack = row_item(&preview_row)?; + let instances = crate::state::list_instances(&state.pool) + .await? + .into_iter() + .map(|item| { + let participating = + global_enabled && item.synced_options_for(project_type); + let versions = game_versions(&preview_row); + let compatible = versions.is_empty() + || versions.iter().any(|version| { + version == &item.applied_content_set.game_version + }); + PackSyncTarget { + instance_id: item.instance.id, + name: item.instance.name, + game_version: item.applied_content_set.game_version, + compatible, + participating, + } + }) + .collect(); + Ok(PackSyncPreview { pack, instances }) +} + +pub async fn sync_pack( + instance_id: &str, + project_path: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let preview = get_pack_sync_preview(instance_id, project_path).await?; + if !globally_enabled(preview.pack.project_type).await? { + return Err(crate::ErrorKind::InputError( + "This sync option is globally disabled.".to_string(), + ) + .into()); + } + if !preview + .instances + .iter() + .any(|target| target.instance_id == instance_id && target.participating) + { + return Err(crate::ErrorKind::InputError( + "This sync option is disabled for the source instance.".to_string(), + ) + .into()); + } + let source_metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let source = content_root(&state, &source_metadata)?.join(project_path); + let bytes = Bytes::from(tokio::fs::read(source).await?); + let sha1 = crate::util::fetch::sha1_async(bytes.clone()).await?; + write_cache(&state, &bytes, &sha1).await?; + let now = Utc::now().timestamp(); + let row = sqlx::query("INSERT INTO synced_pack_catalog(id, project_type, file_name, sha1, size, game_versions_json, enabled, created_at, modified_at) VALUES (?, ?, ?, ?, ?, '[]', 1, ?, ?) ON CONFLICT(sha1) DO UPDATE SET modified_at=excluded.modified_at RETURNING id, project_type, file_name, sha1, size, game_versions_json, enabled").bind(&preview.pack.id).bind(preview.pack.project_type.get_name()).bind(&preview.pack.file_name).bind(&sha1).bind(bytes.len() as i64).bind(now).bind(now).fetch_one(&state.pool).await?; + let catalog = PackRow { + id: row.try_get("id")?, + project_type: row.try_get("project_type")?, + file_name: row.try_get("file_name")?, + sha1: row.try_get("sha1")?, + size: row.try_get("size")?, + game_versions_json: row.try_get("game_versions_json")?, + enabled: row.try_get("enabled")?, + }; + for target in preview + .instances + .into_iter() + .filter(|target| target.participating) + { + materialize(&state, &catalog, &target.instance_id, false).await?; + } + Ok(()) +} + +pub async fn upload_synced_pack( + path: PathBuf, + project_type: ProjectType, + game_versions: Vec, +) -> crate::Result<()> { + validate_type(project_type)?; + let state = State::get().await?; + if !globally_enabled(project_type).await? { + return Err(crate::ErrorKind::InputError( + "This sync option is globally disabled.".to_string(), + ) + .into()); + } + let bytes = Bytes::from(tokio::fs::read(&path).await?); + let sha1 = crate::util::fetch::sha1_async(bytes.clone()).await?; + write_cache(&state, &bytes, &sha1).await?; + let name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + crate::ErrorKind::InputError("Invalid pack filename".to_string()) + })? + .to_string(); + let now = Utc::now().timestamp(); + sqlx::query("INSERT INTO synced_pack_catalog(id, project_type, file_name, sha1, size, game_versions_json, enabled, created_at, modified_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) ON CONFLICT(sha1) DO UPDATE SET file_name=excluded.file_name, game_versions_json=excluded.game_versions_json, modified_at=excluded.modified_at") + .bind(format!("synced-pack:{sha1}")) + .bind(project_type.get_name()).bind(name).bind(&sha1).bind(bytes.len() as i64) + .bind(serde_json::to_string(&game_versions)?).bind(now).bind(now).execute(&state.pool).await?; + Ok(()) +} + +pub async fn set_synced_pack_enabled( + pack_id: &str, + enabled: bool, +) -> crate::Result<()> { + let state = State::get().await?; + let mut row = load_row(pack_id, &state).await?; + if !globally_enabled(parse_type(&row.project_type)?).await? { + return Err(crate::ErrorKind::InputError( + "This sync option is globally disabled.".to_string(), + ) + .into()); + } + sqlx::query("UPDATE synced_pack_catalog SET enabled = ?, modified_at = ? WHERE id = ?").bind(i64::from(enabled)).bind(Utc::now().timestamp()).bind(pack_id).execute(&state.pool).await?; + row.enabled = i64::from(enabled); + for target in sqlx::query("SELECT instance_id FROM synced_pack_instances WHERE pack_id = ? AND excluded = 0").bind(pack_id).fetch_all(&state.pool).await? { materialize(&state, &row, target.try_get("instance_id")?, false).await?; } + Ok(()) +} + +pub async fn desync_pack( + instance_id: &str, + pack_id: &str, + mode: DesyncServerMode, +) -> crate::Result<()> { + let state = State::get().await?; + let row = load_row(pack_id, &state).await?; + materialize(&state, &row, instance_id, true).await?; + if mode == DesyncServerMode::RemoveFromOtherInstances { + let targets = sqlx::query( + "SELECT instance_id FROM synced_pack_instances + WHERE pack_id = ? AND instance_id != ?", + ) + .bind(pack_id) + .bind(instance_id) + .fetch_all(&state.pool) + .await?; + for target in targets { + let target_id: String = target.try_get("instance_id")?; + if let Some(metadata) = + crate::state::get_instance(&target_id, &state.pool).await? + { + cleanup_materialization(&state, &row, &metadata).await?; + } + } + sqlx::query( + "DELETE FROM synced_pack_instances WHERE pack_id = ? AND instance_id != ?", + ) + .bind(pack_id) + .bind(instance_id) + .execute(&state.pool) + .await?; + } + Ok(()) +} + +pub async fn remove_synced_pack(pack_id: &str) -> crate::Result<()> { + let state = State::get().await?; + let row = load_row(pack_id, &state).await?; + let targets = sqlx::query( + "SELECT instance_id FROM synced_pack_instances WHERE pack_id = ?", + ) + .bind(pack_id) + .fetch_all(&state.pool) + .await?; + for target in targets { + let target_id: String = target.try_get("instance_id")?; + if let Some(metadata) = + crate::state::get_instance(&target_id, &state.pool).await? + { + cleanup_materialization(&state, &row, &metadata).await?; + } + } + sqlx::query("DELETE FROM synced_pack_catalog WHERE id = ?") + .bind(pack_id) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub(crate) async fn detach( + metadata: &crate::state::InstanceMetadata, + option: crate::state::SyncedOption, + state: &State, +) -> crate::Result<()> { + let project_type = match option { + crate::state::SyncedOption::ResourcePacks => ProjectType::ResourcePack, + crate::state::SyncedOption::DataPacks => ProjectType::DataPack, + _ => return Ok(()), + }; + let rows = sqlx::query_as::<_, PackRow>( + "SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled + FROM synced_pack_catalog WHERE project_type = ?", + ) + .bind(project_type.get_name()) + .fetch_all(&state.pool) + .await?; + for row in rows { + let excluded = sqlx::query_scalar::<_, i64>( + "SELECT excluded FROM synced_pack_instances + WHERE pack_id = ? AND instance_id = ?", + ) + .bind(&row.id) + .bind(&metadata.instance.id) + .fetch_optional(&state.pool) + .await? + .unwrap_or(0) + != 0; + cleanup_materialization(state, &row, metadata).await?; + record_materialization( + state, + &row.id, + &metadata.instance.id, + excluded, + None, + ) + .await?; + } + Ok(()) +} + +pub(crate) async fn prepare_instance_update( + metadata: &crate::state::InstanceMetadata, + state: &State, +) -> crate::Result<()> { + // Remove only files owned by the shared catalog before an install or + // upgrade writes the instance directory. User-owned files are preserved by + // cleanup_materialization's content hash check and remain local overrides. + for project_type in [ProjectType::ResourcePack, ProjectType::DataPack] { + if !metadata.synced_options_for(project_type) { + continue; + } + let rows = sqlx::query_as::<_, PackRow>( + "SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled + FROM synced_pack_catalog WHERE project_type = ?", + ) + .bind(project_type.get_name()) + .fetch_all(&state.pool) + .await?; + for row in rows { + let excluded = sqlx::query_scalar::<_, i64>( + "SELECT excluded FROM synced_pack_instances + WHERE pack_id = ? AND instance_id = ?", + ) + .bind(&row.id) + .bind(&metadata.instance.id) + .fetch_optional(&state.pool) + .await? + .unwrap_or(0) + != 0; + cleanup_materialization(state, &row, metadata).await?; + record_materialization( + state, + &row.id, + &metadata.instance.id, + excluded, + None, + ) + .await?; + } + } + Ok(()) +} + +pub(crate) async fn reconcile( + metadata: &crate::state::InstanceMetadata, + option: crate::state::SyncedOption, + state: &State, +) -> crate::Result<()> { + let project_type = match option { + crate::state::SyncedOption::ResourcePacks => ProjectType::ResourcePack, + crate::state::SyncedOption::DataPacks => ProjectType::DataPack, + _ => return Ok(()), + }; + let rows = sqlx::query_as::<_, PackRow>( + "SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled + FROM synced_pack_catalog WHERE project_type = ?", + ) + .bind(project_type.get_name()) + .fetch_all(&state.pool) + .await?; + for row in rows { + let excluded = sqlx::query_scalar::<_, i64>( + "SELECT excluded FROM synced_pack_instances WHERE pack_id = ? AND instance_id = ?", + ) + .bind(&row.id) + .bind(&metadata.instance.id) + .fetch_optional(&state.pool) + .await? + .unwrap_or(0) + != 0; + materialize(state, &row, &metadata.instance.id, excluded).await?; + } + Ok(()) +} + +pub(crate) async fn capture_resource_pack_selection_change( + metadata: &crate::state::InstanceMetadata, + state: &State, +) -> crate::Result<()> { + let Some(options) = crate::api::instance::synced_options::game_options::read_resource_pack_entries(metadata, state).await? else { + return Ok(()); + }; + let global_enabled = globally_enabled(ProjectType::ResourcePack).await?; + if !global_enabled || !metadata.synced_options.resource_packs { + return Ok(()); + } + let rows = sqlx::query( + "SELECT id, file_name, selected FROM synced_pack_catalog + WHERE project_type = 'resourcepack'", + ) + .fetch_all(&state.pool) + .await?; + let mut managed = BTreeSet::new(); + let mut selected_names = BTreeSet::new(); + let mut transaction = state.pool.begin().await?; + for (order, row) in rows.iter().enumerate() { + let id: String = row.try_get("id")?; + let file_name: String = row.try_get("file_name")?; + let entry = format!("file/{file_name}"); + let legacy = file_name.clone(); + let is_selected = options + .entries + .iter() + .any(|candidate| candidate == &entry || candidate == &legacy); + managed.insert(entry.clone()); + managed.insert(legacy); + sqlx::query( + "UPDATE synced_pack_catalog + SET selected = ?, selection_order = ? + WHERE id = ?", + ) + .bind(i64::from(is_selected)) + .bind( + options + .entries + .iter() + .position(|candidate| { + candidate == &entry || candidate == &file_name + }) + .map(|value| value as i64) + .or(Some(order as i64)), + ) + .bind(id) + .execute(&mut *transaction) + .await?; + if is_selected { + selected_names.insert(entry); + } + } + transaction.commit().await?; + let selected = options + .entries + .iter() + .filter(|entry| managed.contains(*entry)) + .filter(|entry| { + selected_names.contains(*entry) + || selected_names + .contains(entry.strip_prefix("file/").unwrap_or(entry)) + }) + .cloned() + .collect::>(); + + for target in crate::state::list_instances(&state.pool).await? { + if target.instance.id == metadata.instance.id + || !target.synced_options.resource_packs + { + continue; + } + let target_selected = selected + .iter() + .map(|entry| resource_pack_option_entry(&target, entry)) + .collect::>(); + let _ = crate::api::instance::synced_options::game_options::merge_resource_pack_entries( + &target, + &managed, + &target_selected, + state, + ) + .await?; + } + Ok(()) +} + +pub(crate) async fn seed_from_instance( + metadata: &crate::state::InstanceMetadata, + option: crate::state::SyncedOption, + state: &State, +) -> crate::Result<()> { + let project_type = match option { + crate::state::SyncedOption::ResourcePacks => ProjectType::ResourcePack, + crate::state::SyncedOption::DataPacks => ProjectType::DataPack, + _ => return Ok(()), + }; + let root = content_root(state, metadata)?; + let directory = root.join(project_type.get_folder()); + let mut entries = match tokio::fs::read_dir(&directory).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(()); + } + Err(error) => return Err(error.into()), + }; + let game_versions_json = + serde_json::to_string(&[&metadata.applied_content_set.game_version])?; + let mut seen_sha1 = HashSet::new(); + + while let Some(entry) = entries.next_entry().await? { + if !entry.file_type().await?.is_file() { + continue; + } + let path = entry.path(); + let Some(file_name) = entry.file_name().to_str().map(ToOwned::to_owned) + else { + continue; + }; + let bytes = Bytes::from(tokio::fs::read(&path).await?); + let sha1 = crate::util::fetch::sha1_async(bytes.clone()).await?; + seen_sha1.insert(sha1.clone()); + write_cache(state, &bytes, &sha1).await?; + let enabled = !file_name.ends_with(".disabled"); + let file_name = file_name.trim_end_matches(".disabled"); + let now = Utc::now().timestamp(); + let replaced_rows = sqlx::query_as::<_, PackRow>( + "SELECT id, project_type, file_name, sha1, size, game_versions_json, enabled + FROM synced_pack_catalog + WHERE project_type = ? AND file_name = ? AND sha1 != ?", + ) + .bind(project_type.get_name()) + .bind(file_name) + .bind(&sha1) + .fetch_all(&state.pool) + .await?; + for replaced in replaced_rows { + let targets = sqlx::query( + "SELECT instance_id FROM synced_pack_instances WHERE pack_id = ?", + ) + .bind(&replaced.id) + .fetch_all(&state.pool) + .await?; + for target in targets { + let target_id: String = target.try_get("instance_id")?; + if let Some(target_metadata) = + crate::state::get_instance(&target_id, &state.pool).await? + { + cleanup_materialization(state, &replaced, &target_metadata) + .await?; + } + } + } + let mut transaction = state.pool.begin().await?; + sqlx::query( + "DELETE FROM synced_pack_catalog + WHERE project_type = ? AND file_name = ? AND sha1 != ?", + ) + .bind(project_type.get_name()) + .bind(file_name) + .bind(&sha1) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO synced_pack_catalog + (id, project_type, file_name, sha1, size, game_versions_json, + enabled, created_at, modified_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(sha1) DO UPDATE SET + file_name=excluded.file_name, + game_versions_json=excluded.game_versions_json, + enabled=excluded.enabled, + modified_at=excluded.modified_at", + ) + .bind(format!("synced-pack:{sha1}")) + .bind(project_type.get_name()) + .bind(file_name) + .bind(&sha1) + .bind(bytes.len() as i64) + .bind(&game_versions_json) + .bind(i64::from(enabled)) + .bind(now) + .bind(now) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + + // A watcher event can report a directory after a file was removed. Reconcile + // catalog entries materialized in this source instance so stale files do not + // remain in other participating instances. + let tracked = sqlx::query( + "SELECT c.id, c.project_type, c.file_name, c.sha1, c.size, + c.game_versions_json, c.enabled, i.materialized_path + FROM synced_pack_catalog c + JOIN synced_pack_instances i ON i.pack_id = c.id + WHERE c.project_type = ? AND i.instance_id = ? + AND i.materialized_path IS NOT NULL", + ) + .bind(project_type.get_name()) + .bind(&metadata.instance.id) + .fetch_all(&state.pool) + .await?; + for tracked_row in tracked { + let row = PackRow { + id: tracked_row.try_get("id")?, + project_type: tracked_row.try_get("project_type")?, + file_name: tracked_row.try_get("file_name")?, + sha1: tracked_row.try_get("sha1")?, + size: tracked_row.try_get("size")?, + game_versions_json: tracked_row.try_get("game_versions_json")?, + enabled: tracked_row.try_get("enabled")?, + }; + if seen_sha1.contains(&row.sha1) { + continue; + } + let Some(path) = tracked_row + .try_get::, _>("materialized_path")? + .and_then(|path| normalize_tracked_path(metadata, &path)) + else { + continue; + }; + if tokio::fs::try_exists(&content_root(&state, &metadata)?.join(path)) + .await? + { + continue; + } + let targets = sqlx::query( + "SELECT instance_id FROM synced_pack_instances WHERE pack_id = ?", + ) + .bind(&row.id) + .fetch_all(&state.pool) + .await?; + for target in targets { + let target_id: String = target.try_get("instance_id")?; + if let Some(target_metadata) = + crate::state::get_instance(&target_id, &state.pool).await? + { + cleanup_materialization(state, &row, &target_metadata).await?; + } + } + sqlx::query("DELETE FROM synced_pack_catalog WHERE id = ?") + .bind(&row.id) + .execute(&state.pool) + .await?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logical_paths_preserve_disabled_state() { + assert_eq!( + logical_path(ProjectType::ResourcePack, "example.zip", true), + "resourcepacks/example.zip" + ); + assert_eq!( + logical_path(ProjectType::DataPack, "example.zip", false), + "datapacks/example.zip.disabled" + ); + } + + #[test] + fn unsafe_materialized_paths_are_rejected() { + assert!(safe_relative_path("resourcepacks/example.zip").is_some()); + assert!(safe_relative_path("../outside.zip").is_none()); + assert!(safe_relative_path("/absolute.zip").is_none()); + assert!( + safe_relative_path("resourcepacks/../../outside.zip").is_none() + ); + } + + #[test] + fn malformed_game_version_metadata_is_treated_as_unrestricted() { + let row = PackRow { + id: "pack".to_string(), + project_type: "resourcepack".to_string(), + file_name: "example.zip".to_string(), + sha1: "sha1".to_string(), + size: 1, + game_versions_json: "not-json".to_string(), + enabled: 1, + }; + assert!(game_versions(&row).is_empty()); + } +} diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index 573b716..06c5c0b 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -1835,6 +1835,25 @@ mod tests { } } +pub(crate) async fn set_instance_sync_preference( + instance_id: &str, + option: crate::state::SyncedOption, + enabled: bool, + pool: &SqlitePool, +) -> crate::Result<()> { + sqlx::query( + "INSERT INTO instance_sync_preferences (instance_id, feature, enabled) + VALUES (?, ?, ?) + ON CONFLICT(instance_id, feature) DO UPDATE SET enabled = excluded.enabled", + ) + .bind(instance_id) + .bind(option.as_str()) + .bind(enabled) + .execute(pool) + .await?; + Ok(()) +} + pub(crate) async fn get_instance_synced_options( instance_id: &str, pool: &SqlitePool,