* Implement direct server joining for 1.6.2 through 1.19.4 * Implement direct server joining for versions before 1.6.2 * Ignore methods with a $ in them * Run intl:extract * Improve code of MinecraftTransformer * Support showing last played time for profiles before 1.7 * Reorganize QuickPlayVersion a bit to prepare for singleplayer * Only inject quick play checking in versions where it's needed * Optimize agent some and fix error on NeoForge * Remove some code for quickplay singleplayer support before 1.20, as we can't reasonably support that with an agent * Invert the default hasServerQuickPlaySupport return value * Remove Play Anyway button * Fix "Server couldn't be contacted" on singleplayer worlds * Fix "Jump back in" section not working
919 lines
26 KiB
Rust
919 lines
26 KiB
Rust
use crate::data::ModLoader;
|
|
use crate::launcher::get_loader_version_from_profile;
|
|
use crate::profile::get_full_path;
|
|
use crate::server_address::{parse_server_address, resolve_server_address};
|
|
use crate::state::attached_world_data::AttachedWorldData;
|
|
use crate::state::{
|
|
Profile, ProfileInstallStage, attached_world_data, server_join_log,
|
|
};
|
|
use crate::util::protocol_version::OLD_PROTOCOL_VERSIONS;
|
|
pub use crate::util::protocol_version::ProtocolVersion;
|
|
pub use crate::util::server_ping::{
|
|
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
|
|
};
|
|
use crate::util::{io, server_ping};
|
|
use crate::{ErrorKind, Result, State, launcher};
|
|
use async_walkdir::WalkDir;
|
|
use async_zip::{Compression, ZipEntryBuilder};
|
|
use chrono::{DateTime, Local, TimeZone, Utc};
|
|
use either::Either;
|
|
use enumset::{EnumSet, EnumSetType};
|
|
use fs4::tokio::AsyncFileExt;
|
|
use futures::StreamExt;
|
|
use quartz_nbt::{NbtCompound, NbtTag};
|
|
use regex::{Regex, RegexBuilder};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::cmp::Reverse;
|
|
use std::io::Cursor;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::LazyLock;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::task::JoinSet;
|
|
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
|
use url::Url;
|
|
|
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
|
pub struct WorldWithProfile {
|
|
pub profile: String,
|
|
#[serde(flatten)]
|
|
pub world: World,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
|
pub struct World {
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub last_played: Option<DateTime<Utc>>,
|
|
#[serde(
|
|
skip_serializing_if = "Option::is_none",
|
|
with = "either::serde_untagged_optional"
|
|
)]
|
|
pub icon: Option<Either<PathBuf, Url>>,
|
|
pub display_status: DisplayStatus,
|
|
#[serde(flatten)]
|
|
pub details: WorldDetails,
|
|
}
|
|
|
|
impl World {
|
|
pub fn world_type(&self) -> WorldType {
|
|
match self.details {
|
|
WorldDetails::Singleplayer { .. } => WorldType::Singleplayer,
|
|
WorldDetails::Server { .. } => WorldType::Server,
|
|
}
|
|
}
|
|
|
|
pub fn world_id(&self) -> &str {
|
|
match &self.details {
|
|
WorldDetails::Singleplayer { path, .. } => path,
|
|
WorldDetails::Server { address, .. } => address,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(
|
|
Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, Default,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum WorldType {
|
|
#[default]
|
|
Singleplayer,
|
|
Server,
|
|
}
|
|
|
|
impl WorldType {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Singleplayer => "singleplayer",
|
|
Self::Server => "server",
|
|
}
|
|
}
|
|
|
|
pub fn from_string(string: &str) -> Self {
|
|
match string {
|
|
"singleplayer" => Self::Singleplayer,
|
|
"server" => Self::Server,
|
|
_ => Self::Singleplayer,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, EnumSetType, Debug, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[enumset(serialize_repr = "list")]
|
|
pub enum DisplayStatus {
|
|
#[default]
|
|
Normal,
|
|
Hidden,
|
|
Favorite,
|
|
}
|
|
|
|
impl DisplayStatus {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Normal => "normal",
|
|
Self::Hidden => "hidden",
|
|
Self::Favorite => "favorite",
|
|
}
|
|
}
|
|
|
|
pub fn from_string(string: &str) -> Self {
|
|
match string {
|
|
"normal" => Self::Normal,
|
|
"hidden" => Self::Hidden,
|
|
"favorite" => Self::Favorite,
|
|
_ => Self::Normal,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum WorldDetails {
|
|
Singleplayer {
|
|
path: String,
|
|
game_mode: SingleplayerGameMode,
|
|
hardcore: bool,
|
|
locked: bool,
|
|
},
|
|
Server {
|
|
index: usize,
|
|
address: String,
|
|
pack_status: ServerPackStatus,
|
|
},
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Debug, Copy, Clone, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SingleplayerGameMode {
|
|
#[default]
|
|
Survival,
|
|
Creative,
|
|
Adventure,
|
|
Spectator,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Debug, Copy, Clone, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ServerPackStatus {
|
|
Enabled,
|
|
Disabled,
|
|
#[default]
|
|
Prompt,
|
|
}
|
|
|
|
impl From<Option<bool>> for ServerPackStatus {
|
|
fn from(value: Option<bool>) -> Self {
|
|
match value {
|
|
Some(true) => ServerPackStatus::Enabled,
|
|
Some(false) => ServerPackStatus::Disabled,
|
|
None => ServerPackStatus::Prompt,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ServerPackStatus> for Option<bool> {
|
|
fn from(val: ServerPackStatus) -> Self {
|
|
match val {
|
|
ServerPackStatus::Enabled => Some(true),
|
|
ServerPackStatus::Disabled => Some(false),
|
|
ServerPackStatus::Prompt => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn get_recent_worlds(
|
|
limit: usize,
|
|
display_statuses: EnumSet<DisplayStatus>,
|
|
) -> Result<Vec<WorldWithProfile>> {
|
|
let state = State::get().await?;
|
|
let profiles_dir = state.directories.profiles_dir();
|
|
|
|
let mut profiles = Profile::get_all(&state.pool).await?;
|
|
profiles.sort_by_key(|x| Reverse(x.last_played));
|
|
|
|
let mut result = Vec::with_capacity(limit);
|
|
|
|
let mut least_recent_time = None;
|
|
for profile in profiles {
|
|
if result.len() >= limit && profile.last_played < least_recent_time {
|
|
break;
|
|
}
|
|
let profile_path = &profile.path;
|
|
let profile_dir = profiles_dir.join(profile_path);
|
|
let profile_worlds =
|
|
get_all_worlds_in_profile(profile_path, &profile_dir).await;
|
|
if let Err(e) = profile_worlds {
|
|
tracing::error!(
|
|
"Failed to get worlds for profile {}: {}",
|
|
profile_path,
|
|
e
|
|
);
|
|
continue;
|
|
}
|
|
for world in profile_worlds? {
|
|
let is_older = least_recent_time.is_none()
|
|
|| world.last_played < least_recent_time;
|
|
if result.len() >= limit && is_older {
|
|
continue;
|
|
}
|
|
if !display_statuses.contains(world.display_status) {
|
|
continue;
|
|
}
|
|
if is_older {
|
|
least_recent_time = world.last_played;
|
|
}
|
|
result.push(WorldWithProfile {
|
|
profile: profile_path.clone(),
|
|
world,
|
|
});
|
|
}
|
|
if result.len() > limit {
|
|
result.sort_by_key(|x| Reverse(x.world.last_played));
|
|
result.truncate(limit);
|
|
}
|
|
}
|
|
|
|
if result.len() <= limit {
|
|
result.sort_by_key(|x| Reverse(x.world.last_played));
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn get_profile_worlds(profile_path: &str) -> Result<Vec<World>> {
|
|
get_all_worlds_in_profile(profile_path, &get_full_path(profile_path).await?)
|
|
.await
|
|
}
|
|
|
|
async fn get_all_worlds_in_profile(
|
|
profile_path: &str,
|
|
profile_dir: &Path,
|
|
) -> Result<Vec<World>> {
|
|
let mut worlds = vec![];
|
|
get_singleplayer_worlds_in_profile(profile_dir, &mut worlds).await?;
|
|
get_server_worlds_in_profile(profile_path, profile_dir, &mut worlds)
|
|
.await?;
|
|
|
|
let state = State::get().await?;
|
|
let attached_data =
|
|
AttachedWorldData::get_all_for_instance(profile_path, &state.pool)
|
|
.await?;
|
|
if !attached_data.is_empty() {
|
|
for world in &mut worlds {
|
|
if let Some(data) = attached_data
|
|
.get(&(world.world_type(), world.world_id().to_owned()))
|
|
{
|
|
attach_world_data_to_world(world, data);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(worlds)
|
|
}
|
|
|
|
async fn get_singleplayer_worlds_in_profile(
|
|
instance_dir: &Path,
|
|
worlds: &mut Vec<World>,
|
|
) -> Result<()> {
|
|
let saves_dir = instance_dir.join("saves");
|
|
if !saves_dir.exists() {
|
|
return Ok(());
|
|
}
|
|
let mut saves_dir = io::read_dir(saves_dir).await?;
|
|
while let Some(world_dir) = saves_dir.next_entry().await? {
|
|
let world_path = world_dir.path();
|
|
let level_dat_path = world_path.join("level.dat");
|
|
if !level_dat_path.exists() {
|
|
continue;
|
|
}
|
|
if let Ok(world) = read_singleplayer_world(world_path).await {
|
|
worlds.push(world);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_singleplayer_world(
|
|
instance: &str,
|
|
world: &str,
|
|
) -> Result<World> {
|
|
let state = State::get().await?;
|
|
let profile_path = state.directories.profiles_dir().join(instance);
|
|
let mut world =
|
|
read_singleplayer_world(get_world_dir(&profile_path, world)).await?;
|
|
|
|
if let Some(data) = AttachedWorldData::get_for_world(
|
|
instance,
|
|
world.world_type(),
|
|
world.world_id(),
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
{
|
|
attach_world_data_to_world(&mut world, &data);
|
|
}
|
|
Ok(world)
|
|
}
|
|
|
|
async fn read_singleplayer_world(world_path: PathBuf) -> Result<World> {
|
|
if let Some(_lock) = try_get_world_session_lock(&world_path).await? {
|
|
read_singleplayer_world_maybe_locked(world_path, false).await
|
|
} else {
|
|
read_singleplayer_world_maybe_locked(world_path, true).await
|
|
}
|
|
}
|
|
|
|
async fn read_singleplayer_world_maybe_locked(
|
|
world_path: PathBuf,
|
|
locked: bool,
|
|
) -> Result<World> {
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct LevelDataRoot {
|
|
data: LevelData,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct LevelData {
|
|
#[serde(default)]
|
|
level_name: String,
|
|
#[serde(default)]
|
|
last_played: i64,
|
|
#[serde(default)]
|
|
game_type: i32,
|
|
#[serde(default, rename = "hardcore")]
|
|
hardcore: bool,
|
|
}
|
|
|
|
let level_data = io::read(world_path.join("level.dat")).await?;
|
|
let level_data: LevelDataRoot = quartz_nbt::serde::deserialize(
|
|
&level_data,
|
|
quartz_nbt::io::Flavor::GzCompressed,
|
|
)?
|
|
.0;
|
|
let level_data = level_data.data;
|
|
|
|
let icon = Some(world_path.join("icon.png")).filter(|i| i.exists());
|
|
|
|
let game_mode = match level_data.game_type {
|
|
0 => SingleplayerGameMode::Survival,
|
|
1 => SingleplayerGameMode::Creative,
|
|
2 => SingleplayerGameMode::Adventure,
|
|
3 => SingleplayerGameMode::Spectator,
|
|
_ => SingleplayerGameMode::Survival,
|
|
};
|
|
|
|
Ok(World {
|
|
name: level_data.level_name,
|
|
last_played: Utc.timestamp_millis_opt(level_data.last_played).single(),
|
|
icon: icon.map(Either::Left),
|
|
display_status: DisplayStatus::Normal,
|
|
details: WorldDetails::Singleplayer {
|
|
path: world_path
|
|
.file_name()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
game_mode,
|
|
hardcore: level_data.hardcore,
|
|
locked,
|
|
},
|
|
})
|
|
}
|
|
|
|
async fn get_server_worlds_in_profile(
|
|
profile_path: &str,
|
|
instance_dir: &Path,
|
|
worlds: &mut Vec<World>,
|
|
) -> Result<()> {
|
|
let servers = servers_data::read(instance_dir).await?;
|
|
if servers.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let state = State::get().await?;
|
|
let join_log = server_join_log::get_joins(profile_path, &state.pool)
|
|
.await
|
|
.ok();
|
|
|
|
let first_server_index = worlds.len();
|
|
for (index, server) in servers.into_iter().enumerate() {
|
|
if server.hidden {
|
|
// TODO: Figure out whether we want to hide or show direct connect servers
|
|
continue;
|
|
}
|
|
let world = World {
|
|
name: server.name,
|
|
last_played: join_log
|
|
.as_ref()
|
|
.and_then(|log| {
|
|
let (host, port) = parse_server_address(&server.ip).ok()?;
|
|
log.get(&(host.to_owned(), port))
|
|
})
|
|
.copied(),
|
|
icon: server
|
|
.icon
|
|
.and_then(|icon| {
|
|
Url::parse(&format!("data:image/png;base64,{icon}")).ok()
|
|
})
|
|
.map(Either::Right),
|
|
display_status: DisplayStatus::Normal,
|
|
details: WorldDetails::Server {
|
|
index,
|
|
address: server.ip,
|
|
pack_status: server.accept_textures.into(),
|
|
},
|
|
};
|
|
worlds.push(world);
|
|
}
|
|
|
|
if let Some(join_log) = join_log {
|
|
let mut futures = JoinSet::new();
|
|
for (index, world) in worlds.iter().enumerate().skip(first_server_index)
|
|
{
|
|
// We can't check for the profile already having a last_played, in case the user joined
|
|
// the target address directly more recently. This is often the case when using
|
|
// quick-play before 1.20.
|
|
if let WorldDetails::Server { address, .. } = &world.details
|
|
&& let Ok((host, port)) = parse_server_address(address)
|
|
{
|
|
let host = host.to_owned();
|
|
futures.spawn(async move {
|
|
resolve_server_address(&host, port)
|
|
.await
|
|
.ok()
|
|
.map(|x| (index, x))
|
|
});
|
|
}
|
|
}
|
|
for (index, address) in futures.join_all().await.into_iter().flatten() {
|
|
worlds[index].last_played = join_log.get(&address).copied();
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn attach_world_data_to_world(world: &mut World, data: &AttachedWorldData) {
|
|
world.display_status = data.display_status;
|
|
}
|
|
|
|
pub async fn set_world_display_status(
|
|
instance: &str,
|
|
world_type: WorldType,
|
|
world_id: &str,
|
|
display_status: DisplayStatus,
|
|
) -> Result<()> {
|
|
let state = State::get().await?;
|
|
attached_world_data::set_display_status(
|
|
instance,
|
|
world_type,
|
|
world_id,
|
|
display_status,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn rename_world(
|
|
instance: &Path,
|
|
world: &str,
|
|
new_name: &str,
|
|
) -> Result<()> {
|
|
let world = get_world_dir(instance, world);
|
|
let level_dat_path = world.join("level.dat");
|
|
if !level_dat_path.exists() {
|
|
return Ok(());
|
|
}
|
|
let _lock = get_world_session_lock(&world).await?;
|
|
|
|
let level_data = io::read(&level_dat_path).await?;
|
|
let (mut root_data, _) = quartz_nbt::io::read_nbt(
|
|
&mut Cursor::new(level_data),
|
|
quartz_nbt::io::Flavor::GzCompressed,
|
|
)?;
|
|
let data = root_data.get_mut::<_, &mut NbtCompound>("Data")?;
|
|
|
|
data.insert(
|
|
"LevelName",
|
|
NbtTag::String(new_name.trim_ascii().to_string()),
|
|
);
|
|
|
|
let mut level_data = vec![];
|
|
quartz_nbt::io::write_nbt(
|
|
&mut level_data,
|
|
None,
|
|
&root_data,
|
|
quartz_nbt::io::Flavor::GzCompressed,
|
|
)?;
|
|
io::write(level_dat_path, level_data).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reset_world_icon(instance: &Path, world: &str) -> Result<()> {
|
|
let world = get_world_dir(instance, world);
|
|
let icon = world.join("icon.png");
|
|
if let Some(_lock) = try_get_world_session_lock(&world).await? {
|
|
let _ = io::remove_file(icon).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn backup_world(instance: &Path, world: &str) -> Result<u64> {
|
|
let world_dir = get_world_dir(instance, world);
|
|
let _lock = get_world_session_lock(&world_dir).await?;
|
|
let backups_dir = instance.join("backups");
|
|
|
|
io::create_dir_all(&backups_dir).await?;
|
|
|
|
let name_base = {
|
|
let now = Local::now();
|
|
let formatted_time = now.format("%Y-%m-%d_%H-%M-%S");
|
|
format!("{formatted_time}_{world}")
|
|
};
|
|
let output_path =
|
|
backups_dir.join(find_available_name(&backups_dir, &name_base, ".zip"));
|
|
|
|
let writer = tokio::fs::File::create(&output_path).await?;
|
|
let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(writer);
|
|
|
|
let mut walker = WalkDir::new(&world_dir);
|
|
while let Some(entry) = walker.next().await {
|
|
let entry = entry.map_err(|e| io::IOError::IOPathError {
|
|
path: e.path().unwrap().to_string_lossy().to_string(),
|
|
source: e.into_io().unwrap(),
|
|
})?;
|
|
if !entry.file_type().await?.is_file() {
|
|
continue;
|
|
}
|
|
if entry.file_name() == "session.lock" {
|
|
continue;
|
|
}
|
|
let zip_filename = format!(
|
|
"{world}/{}",
|
|
entry
|
|
.path()
|
|
.strip_prefix(&world_dir)?
|
|
.display()
|
|
.to_string()
|
|
.replace('\\', "/")
|
|
);
|
|
let mut stream = writer
|
|
.write_entry_stream(
|
|
ZipEntryBuilder::new(zip_filename.into(), Compression::Deflate)
|
|
.build(),
|
|
)
|
|
.await?
|
|
.compat_write();
|
|
let mut source = tokio::fs::File::open(entry.path()).await?;
|
|
tokio::io::copy(&mut source, &mut stream).await?;
|
|
stream.into_inner().close().await?;
|
|
}
|
|
|
|
writer.close().await?;
|
|
Ok(io::metadata(output_path).await?.len())
|
|
}
|
|
|
|
fn find_available_name(dir: &Path, file_name: &str, extension: &str) -> String {
|
|
static RESERVED_WINDOWS_FILENAMES: LazyLock<Regex> = LazyLock::new(|| {
|
|
RegexBuilder::new(r#"^.*\.|(?:COM|CLOCK\$|CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$"#)
|
|
.case_insensitive(true)
|
|
.build()
|
|
.unwrap()
|
|
});
|
|
static COPY_COUNTER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
|
RegexBuilder::new(r#"^(?<name>.*) \((?<count>\d*)\)$"#)
|
|
.case_insensitive(true)
|
|
.unicode(true)
|
|
.build()
|
|
.unwrap()
|
|
});
|
|
|
|
let mut file_name = file_name.replace(
|
|
[
|
|
'/', '\n', '\r', '\t', '\0', '\x0c', '`', '?', '*', '\\', '<', '>',
|
|
'|', '"', ':', '.', '/', '"',
|
|
],
|
|
"_",
|
|
);
|
|
if RESERVED_WINDOWS_FILENAMES.is_match(&file_name) {
|
|
file_name.insert(0, '_');
|
|
file_name.push('_');
|
|
}
|
|
|
|
let mut count = 0;
|
|
if let Some(find) = COPY_COUNTER_PATTERN.captures(&file_name) {
|
|
count = find
|
|
.name("count")
|
|
.unwrap()
|
|
.as_str()
|
|
.parse::<i32>()
|
|
.unwrap_or(0);
|
|
let end = find.name("name").unwrap().end();
|
|
drop(find);
|
|
file_name.truncate(end);
|
|
}
|
|
|
|
if file_name.len() > 255 - extension.len() {
|
|
file_name.truncate(255 - extension.len());
|
|
}
|
|
|
|
let mut current_attempt = file_name.clone();
|
|
loop {
|
|
if count != 0 {
|
|
let with_count = format!(" ({count})");
|
|
if file_name.len() > 255 - with_count.len() {
|
|
current_attempt.truncate(255 - with_count.len());
|
|
}
|
|
current_attempt.push_str(&with_count);
|
|
}
|
|
|
|
current_attempt.push_str(extension);
|
|
|
|
let result = dir.join(¤t_attempt);
|
|
if !result.exists() {
|
|
return current_attempt;
|
|
}
|
|
|
|
count += 1;
|
|
current_attempt.replace_range(..current_attempt.len(), &file_name);
|
|
}
|
|
}
|
|
|
|
pub async fn delete_world(instance: &Path, world: &str) -> Result<()> {
|
|
let world = get_world_dir(instance, world);
|
|
let lock = get_world_session_lock(&world).await?;
|
|
let lock_path = world.join("session.lock");
|
|
|
|
let mut dir = io::read_dir(&world).await?;
|
|
while let Some(entry) = dir.next_entry().await? {
|
|
let path = entry.path();
|
|
if entry.file_type().await?.is_dir() {
|
|
io::remove_dir_all(path).await?;
|
|
continue;
|
|
}
|
|
if path != lock_path {
|
|
io::remove_file(path).await?;
|
|
}
|
|
}
|
|
|
|
drop(lock);
|
|
io::remove_file(lock_path).await?;
|
|
io::remove_dir(world).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn get_world_dir(instance: &Path, world: &str) -> PathBuf {
|
|
instance.join("saves").join(world)
|
|
}
|
|
|
|
async fn get_world_session_lock(world: &Path) -> Result<tokio::fs::File> {
|
|
let lock_path = world.join("session.lock");
|
|
let mut file = tokio::fs::File::options()
|
|
.create(true)
|
|
.write(true)
|
|
.truncate(false)
|
|
.open(&lock_path)
|
|
.await?;
|
|
file.write_all("☃".as_bytes()).await?;
|
|
file.sync_all().await?;
|
|
let locked = file.try_lock_exclusive()?;
|
|
locked.then_some(file).ok_or_else(|| {
|
|
io::IOError::IOPathError {
|
|
source: std::io::Error::new(
|
|
std::io::ErrorKind::ResourceBusy,
|
|
"already locked by Minecraft",
|
|
),
|
|
path: lock_path.to_string_lossy().into_owned(),
|
|
}
|
|
.into()
|
|
})
|
|
}
|
|
|
|
async fn try_get_world_session_lock(
|
|
world: &Path,
|
|
) -> Result<Option<tokio::fs::File>> {
|
|
let file = tokio::fs::File::options()
|
|
.create(true)
|
|
.write(true)
|
|
.truncate(false)
|
|
.open(world.join("session.lock"))
|
|
.await?;
|
|
file.sync_all().await?;
|
|
let locked = file.try_lock_exclusive()?;
|
|
Ok(locked.then_some(file))
|
|
}
|
|
|
|
pub async fn add_server_to_profile(
|
|
profile_path: &Path,
|
|
name: String,
|
|
address: String,
|
|
pack_status: ServerPackStatus,
|
|
) -> Result<usize> {
|
|
let mut servers = servers_data::read(profile_path).await?;
|
|
let insert_index = servers
|
|
.iter()
|
|
.position(|x| x.hidden)
|
|
.unwrap_or(servers.len());
|
|
servers.insert(
|
|
insert_index,
|
|
servers_data::ServerData {
|
|
name,
|
|
ip: address,
|
|
accept_textures: pack_status.into(),
|
|
hidden: false,
|
|
icon: None,
|
|
},
|
|
);
|
|
servers_data::write(profile_path, &servers).await?;
|
|
Ok(insert_index)
|
|
}
|
|
|
|
pub async fn edit_server_in_profile(
|
|
profile_path: &Path,
|
|
index: usize,
|
|
name: String,
|
|
address: String,
|
|
pack_status: ServerPackStatus,
|
|
) -> Result<()> {
|
|
let mut servers = servers_data::read(profile_path).await?;
|
|
let server =
|
|
servers
|
|
.get_mut(index)
|
|
.filter(|x| !x.hidden)
|
|
.ok_or_else(|| {
|
|
ErrorKind::InputError(format!(
|
|
"No editable server at index {index}"
|
|
))
|
|
.as_error()
|
|
})?;
|
|
server.name = name;
|
|
server.ip = address;
|
|
server.accept_textures = pack_status.into();
|
|
servers_data::write(profile_path, &servers).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn remove_server_from_profile(
|
|
profile_path: &Path,
|
|
index: usize,
|
|
) -> Result<()> {
|
|
let mut servers = servers_data::read(profile_path).await?;
|
|
if servers.get(index).filter(|x| !x.hidden).is_none() {
|
|
return Err(ErrorKind::InputError(format!(
|
|
"No removable server at index {index}"
|
|
))
|
|
.into());
|
|
}
|
|
servers.remove(index);
|
|
servers_data::write(profile_path, &servers).await?;
|
|
Ok(())
|
|
}
|
|
|
|
mod servers_data {
|
|
use crate::Result;
|
|
use crate::util::io;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ServerData {
|
|
#[serde(default)]
|
|
pub hidden: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub icon: Option<String>,
|
|
#[serde(default)]
|
|
pub ip: String,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub accept_textures: Option<bool>,
|
|
}
|
|
|
|
pub async fn read(instance_dir: &Path) -> Result<Vec<ServerData>> {
|
|
#[derive(Deserialize, Debug)]
|
|
struct ServersData {
|
|
#[serde(default)]
|
|
servers: Vec<ServerData>,
|
|
}
|
|
|
|
let servers_dat_path = instance_dir.join("servers.dat");
|
|
if !servers_dat_path.exists() {
|
|
return Ok(vec![]);
|
|
}
|
|
let servers_data = io::read(servers_dat_path).await?;
|
|
let servers_data: ServersData = quartz_nbt::serde::deserialize(
|
|
&servers_data,
|
|
quartz_nbt::io::Flavor::Uncompressed,
|
|
)?
|
|
.0;
|
|
Ok(servers_data.servers)
|
|
}
|
|
|
|
pub async fn write(
|
|
instance_dir: &Path,
|
|
servers: &[ServerData],
|
|
) -> Result<()> {
|
|
#[derive(Serialize, Debug)]
|
|
struct ServersData<'a> {
|
|
servers: &'a [ServerData],
|
|
}
|
|
|
|
let servers_dat_path = instance_dir.join("servers.dat");
|
|
let data = quartz_nbt::serde::serialize(
|
|
&ServersData { servers },
|
|
None,
|
|
quartz_nbt::io::Flavor::Uncompressed,
|
|
)?;
|
|
io::write(servers_dat_path, data).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub async fn get_profile_protocol_version(
|
|
profile: &str,
|
|
) -> Result<Option<ProtocolVersion>> {
|
|
let mut profile = super::profile::get(profile).await?.ok_or_else(|| {
|
|
ErrorKind::UnmanagedProfileError(format!(
|
|
"Could not find profile {profile}"
|
|
))
|
|
})?;
|
|
if profile.install_stage != ProfileInstallStage::Installed {
|
|
return Ok(None);
|
|
}
|
|
|
|
if let Some(protocol_version) = profile.protocol_version {
|
|
return Ok(Some(ProtocolVersion::modern(protocol_version)));
|
|
}
|
|
if let Some(protocol_version) =
|
|
OLD_PROTOCOL_VERSIONS.get(&profile.game_version)
|
|
{
|
|
return Ok(Some(*protocol_version));
|
|
}
|
|
|
|
let minecraft = crate::api::metadata::get_minecraft_versions().await?;
|
|
let version_index = minecraft
|
|
.versions
|
|
.iter()
|
|
.position(|it| it.id == profile.game_version)
|
|
.ok_or(ErrorKind::LauncherError(format!(
|
|
"Invalid game version: {}",
|
|
profile.game_version
|
|
)))?;
|
|
let version = &minecraft.versions[version_index];
|
|
|
|
let loader_version = get_loader_version_from_profile(
|
|
&profile.game_version,
|
|
profile.loader,
|
|
profile.loader_version.as_deref(),
|
|
)
|
|
.await?;
|
|
if profile.loader != ModLoader::Vanilla && loader_version.is_none() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let version_jar =
|
|
loader_version.as_ref().map_or(version.id.clone(), |it| {
|
|
format!("{}-{}", version.id.clone(), it.id.clone())
|
|
});
|
|
|
|
let state = State::get().await?;
|
|
let client_path = state
|
|
.directories
|
|
.version_dir(&version_jar)
|
|
.join(format!("{version_jar}.jar"));
|
|
|
|
if !client_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let version = launcher::read_protocol_version_from_jar(client_path).await?;
|
|
if version.is_some() {
|
|
profile.protocol_version = version;
|
|
profile.upsert(&state.pool).await?;
|
|
}
|
|
Ok(version.map(ProtocolVersion::modern))
|
|
}
|
|
|
|
pub async fn get_server_status(
|
|
address: &str,
|
|
protocol_version: Option<ProtocolVersion>,
|
|
) -> Result<ServerStatus> {
|
|
let (original_host, original_port) = parse_server_address(address)?;
|
|
let (host, port) =
|
|
resolve_server_address(original_host, original_port).await?;
|
|
tracing::debug!(
|
|
"Pinging {address} with protocol version {protocol_version:?}"
|
|
);
|
|
server_ping::get_server_status(
|
|
&(&host as &str, port),
|
|
(original_host, original_port),
|
|
protocol_version,
|
|
)
|
|
.await
|
|
}
|