You've already forked AstralRinth
forked from didirus/AstralRinth
Direct World Joining (#3457)
* Begin work on worlds backend * Finish implementing get_profile_worlds and get_server_status (except pinning) * Create TS types and manually copy unparsed chat components * Clippy fix * Update types.d.ts * Initial worlds UI work * Fix api::get_profile_worlds to take in a relative path * sanitize & security update * Fix sanitizePotentialFileUrl * Fix sanitizePotentialFileUrl (for real) * Fix empty motd causing error * Finally actually fix world icons * Fix world icon not being visible on non-Windows * Use the correct generics to take in AppHandle * Implement start_join_singleplayer_world and start_join_server for modern versions * Don't error if server has no cached icon * Migrate to own server pinging * Ignore missing server hidden field and missing saves dir * Update world list frontend * More frontend work * Server status player sample can be absent * Fix refresh state * Add get_profile_protocol_version * Add protocol_version column to database * SQL INTEGER is i64 in sqlx * sqlx prepare * Cache protocol version in database * Continue worlds UI work * Fix motds being bold * Remove legacy pinging and add a 30-second timeout * Remove pinned for now and match world (and server) parsing closer to spec * Move type ServerStatus to worlds.ts * Implement add_server_to_profile * Fix pack_status being ignored when joining from launcher * Make World path field be relative * Implement rename_world and reset_world_icon * Clippy fix * Fix rename_world * UI enhancements * Implement backup_world, which returns the backup size in bytes * Clippy fix * Return index when adding servers to profile * Fix backup * Implement delete_world * Implement edit_server_in_profile and remove_server_from_profile * Clippy fix * Log server joins * Add edit and delete support * Fix ts errors * Fix minecraft font * Switch font out for non-monospaced. * Fix font proper * Some more world cleanup, handle play state, check quickplay compatibility * Clear the cached protocol version when a profile's game version is changed * Fix tint colors in navbar * Fix server protocol version pinging * UI fixes * Fix protocol version handler * Fix MOTD parsing * Add worlds_updated profile event * fix pkg * Functional home screen with worlds * lint * Fix incorrect folder creation * Make items clickable * Add locked field to SingleplayerWorld indicating whether the world is locked by the game * Implement locking frontend * Fix locking condition * Split worlds_updated profile event into servers_updated and world_updated * Fix compile error * Use port from resolve SRV record * Fix serialization of ProfilePayload and ProfilePayloadType * Individual singleplayer world refreshing * Log when worlds are perceived to be updated * Push logging + total refresh lock * Unlisten fixes * Highlight current world when clicked * Launcher logs refactor (#3444) * Switch live log to use STDOUT * fix clippy, legacy logs support * Fix lint * Handle non-XML log messages in XML logging, and don't escape log messages into XML --------- Co-authored-by: Josiah Glosson <soujournme@gmail.com> * Update incompatibility text * Home page fixes, and unlock after close * Remove logging * Add join log database migration * Switch server join timing to being in the database instead of in a separate log file * Create optimized get_recent_worlds function that takes in a limit * Update dependencies and fix Cargo.lock * temp disable overflow menus * revert home page changes * Enable overflow menus again * Remove list * Revert * Push dev tools * Remove default filter * Disable debug renderer * Fix random app errors * Refactor * Fix missing computed import * Fix light mode issues * Fix TS errors * Lint * Fix bad link in change modpack version modal * fix lint * fix intl --------- Co-authored-by: Josiah Glosson <soujournme@gmail.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
@@ -298,7 +298,7 @@ pub async fn get_latest_log_cursor(
|
||||
profile_path: &str,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> crate::Result<LatestLogCursor> {
|
||||
get_generic_live_log_cursor(profile_path, "latest.log", cursor).await
|
||||
get_generic_live_log_cursor(profile_path, "launcher_log.txt", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod process;
|
||||
pub mod profile;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
pub mod worlds;
|
||||
|
||||
pub mod data {
|
||||
pub use crate::state::{
|
||||
|
||||
@@ -77,6 +77,7 @@ pub async fn profile_create(
|
||||
name,
|
||||
icon_path: None,
|
||||
game_version,
|
||||
protocol_version: None,
|
||||
loader: modloader,
|
||||
loader_version: loader.map(|x| x.id),
|
||||
groups: Vec::new(),
|
||||
|
||||
@@ -36,6 +36,13 @@ use tokio::{fs::File, process::Command, sync::RwLock};
|
||||
pub mod create;
|
||||
pub mod update;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum QuickPlayType {
|
||||
None,
|
||||
Singleplayer(String),
|
||||
Server(String),
|
||||
}
|
||||
|
||||
/// Remove a profile
|
||||
#[tracing::instrument]
|
||||
pub async fn remove(path: &str) -> crate::Result<()> {
|
||||
@@ -623,14 +630,17 @@ fn pack_get_relative_path(
|
||||
/// Run Minecraft using a profile and the default credentials, logged in credentials,
|
||||
/// failing with an error if no credentials are available
|
||||
#[tracing::instrument]
|
||||
pub async fn run(path: &str) -> crate::Result<ProcessMetadata> {
|
||||
pub async fn run(
|
||||
path: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
let state = State::get().await?;
|
||||
|
||||
let default_account = Credentials::get_default_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?;
|
||||
|
||||
run_credentials(path, &default_account).await
|
||||
run_credentials(path, &default_account, quick_play_type).await
|
||||
}
|
||||
|
||||
/// Run Minecraft using a profile, and credentials for authentication
|
||||
@@ -640,6 +650,7 @@ pub async fn run(path: &str) -> crate::Result<ProcessMetadata> {
|
||||
pub async fn run_credentials(
|
||||
path: &str,
|
||||
credentials: &Credentials,
|
||||
quick_play_type: &QuickPlayType,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
let state = State::get().await?;
|
||||
let settings = Settings::get(&state.pool).await?;
|
||||
@@ -719,6 +730,7 @@ pub async fn run_credentials(
|
||||
credentials,
|
||||
post_exit_hook,
|
||||
&profile,
|
||||
quick_play_type,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
830
packages/app-lib/src/api/worlds.rs
Normal file
830
packages/app-lib/src/api/worlds.rs
Normal file
@@ -0,0 +1,830 @@
|
||||
use crate::data::ModLoader;
|
||||
use crate::launcher::get_loader_version_from_profile;
|
||||
use crate::profile::get_full_path;
|
||||
use crate::state::{server_join_log, Profile, ProfileInstallStage};
|
||||
pub use crate::util::server_ping::{
|
||||
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
|
||||
};
|
||||
use crate::util::{io, server_ping};
|
||||
use crate::{launcher, Error, ErrorKind, Result, State};
|
||||
use async_walkdir::WalkDir;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use chrono::{DateTime, Local, TimeZone, Utc};
|
||||
use either::Either;
|
||||
use fs4::tokio::AsyncFileExt;
|
||||
use futures::StreamExt;
|
||||
use lazy_static::lazy_static;
|
||||
use quartz_nbt::{NbtCompound, NbtTag};
|
||||
use regex::{Regex, RegexBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Reverse;
|
||||
use std::io::Cursor;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
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>>,
|
||||
#[serde(flatten)]
|
||||
pub details: WorldDetails,
|
||||
}
|
||||
|
||||
#[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) -> 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 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?;
|
||||
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(
|
||||
profile_path: &Path,
|
||||
world: &str,
|
||||
) -> Result<World> {
|
||||
read_singleplayer_world(get_world_dir(profile_path, world)).await
|
||||
}
|
||||
|
||||
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),
|
||||
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();
|
||||
|
||||
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 icon = server.icon.and_then(|icon| {
|
||||
Url::parse(&format!("data:image/png;base64,{}", icon)).ok()
|
||||
});
|
||||
let last_played = join_log
|
||||
.as_ref()
|
||||
.and_then(|log| {
|
||||
let address = parse_server_address(&server.ip).ok()?;
|
||||
log.get(&(address.0.to_owned(), address.1))
|
||||
})
|
||||
.copied();
|
||||
let world = World {
|
||||
name: server.name,
|
||||
last_played,
|
||||
icon: icon.map(Either::Right),
|
||||
details: WorldDetails::Server {
|
||||
index,
|
||||
address: server.ip,
|
||||
pack_status: server.accept_textures.into(),
|
||||
},
|
||||
};
|
||||
worlds.push(world);
|
||||
}
|
||||
|
||||
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 {
|
||||
lazy_static! {
|
||||
static ref RESERVED_WINDOWS_FILENAMES: Regex = RegexBuilder::new(r#"^.*\.|(?:COM|CLOCK\$|CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$"#)
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
static ref COPY_COUNTER_PATTERN: Regex = 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::util::io;
|
||||
use crate::Result;
|
||||
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<i32>> {
|
||||
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(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(crate::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)
|
||||
}
|
||||
|
||||
pub async fn get_server_status(
|
||||
address: &str,
|
||||
protocol_version: Option<i32>,
|
||||
) -> Result<ServerStatus> {
|
||||
let (original_host, original_port) = parse_server_address(address)?;
|
||||
let (host, port) =
|
||||
resolve_server_address(original_host, original_port).await?;
|
||||
server_ping::get_server_status(
|
||||
&(&host as &str, port),
|
||||
(original_host, original_port),
|
||||
protocol_version,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
|
||||
parse_server_address_inner(address)
|
||||
.map_err(|e| Error::from(ErrorKind::InputError(e)))
|
||||
}
|
||||
|
||||
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
|
||||
fn parse_server_address_inner(
|
||||
address: &str,
|
||||
) -> std::result::Result<(&str, u16), String> {
|
||||
let (host, port_str) = if address.starts_with("[") {
|
||||
let colon_index = address.find(':');
|
||||
let close_bracket_index = address.rfind(']');
|
||||
if colon_index.is_none() || close_bracket_index.is_none() {
|
||||
return Err(format!("Invalid bracketed host/port: {address}"));
|
||||
}
|
||||
let close_bracket_index = close_bracket_index.unwrap();
|
||||
|
||||
let host = &address[1..close_bracket_index];
|
||||
if close_bracket_index + 1 == address.len() {
|
||||
(host, "")
|
||||
} else {
|
||||
if address.as_bytes().get(close_bracket_index).copied()
|
||||
!= Some(b':')
|
||||
{
|
||||
return Err(format!(
|
||||
"Only a colon may follow a close bracket: {address}"
|
||||
));
|
||||
}
|
||||
let port_str = &address[close_bracket_index + 2..];
|
||||
for c in port_str.chars() {
|
||||
if !c.is_ascii_digit() {
|
||||
return Err(format!("Port must be numeric: {address}"));
|
||||
}
|
||||
}
|
||||
(host, port_str)
|
||||
}
|
||||
} else {
|
||||
let colon_pos = address.find(':');
|
||||
if let Some(colon_pos) = colon_pos {
|
||||
(&address[..colon_pos], &address[colon_pos + 1..])
|
||||
} else {
|
||||
(address, "")
|
||||
}
|
||||
};
|
||||
|
||||
let mut port = None;
|
||||
if !port_str.is_empty() {
|
||||
if port_str.starts_with('+') {
|
||||
return Err(format!("Unparseable port number: {port_str}"));
|
||||
}
|
||||
port = port_str.parse::<u16>().ok();
|
||||
if port.is_none() {
|
||||
return Err(format!("Unparseable port number: {port_str}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((host, port.unwrap_or(25565)))
|
||||
}
|
||||
|
||||
async fn resolve_server_address(
|
||||
host: &str,
|
||||
port: u16,
|
||||
) -> Result<(String, u16)> {
|
||||
if host.parse::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().is_ok() {
|
||||
return Ok((host.to_owned(), port));
|
||||
}
|
||||
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
|
||||
Ok(match resolver
|
||||
.srv_lookup(format!("_minecraft._tcp.{}", host))
|
||||
.await
|
||||
{
|
||||
Err(e)
|
||||
if e.proto()
|
||||
.filter(|x| x.kind().is_no_records_found())
|
||||
.is_some() =>
|
||||
{
|
||||
None
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
Ok(lookup) => lookup
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|r| (r.target().to_string(), r.port())),
|
||||
}
|
||||
.unwrap_or_else(|| (host.to_owned(), port)))
|
||||
}
|
||||
@@ -13,6 +13,12 @@ pub enum ErrorKind {
|
||||
#[error("Serialization error (JSON): {0}")]
|
||||
JSONError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Serialization error (NBT): {0}")]
|
||||
NBTError(#[from] quartz_nbt::io::NbtIoError),
|
||||
|
||||
#[error("NBT data structure error: {0}")]
|
||||
NBTReprError(#[from] quartz_nbt::NbtReprError),
|
||||
|
||||
#[error("Serialization error (websocket): {0}")]
|
||||
WebsocketSerializationError(
|
||||
#[from] ariadne::networking::serialization::SerializationError,
|
||||
@@ -116,6 +122,9 @@ pub enum ErrorKind {
|
||||
|
||||
#[error("Move directory error: {0}")]
|
||||
DirectoryMoveError(String),
|
||||
|
||||
#[error("Error resolving DNS: {0}")]
|
||||
DNSError(#[from] hickory_resolver::ResolveError),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Theseus state management system
|
||||
use ariadne::users::{UserId, UserStatus};
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
@@ -234,13 +235,23 @@ pub enum ProcessPayloadType {
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ProfilePayload {
|
||||
pub profile_path_id: String,
|
||||
#[serde(flatten)]
|
||||
pub event: ProfilePayloadType,
|
||||
}
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum ProfilePayloadType {
|
||||
Created,
|
||||
Synced,
|
||||
ServersUpdated,
|
||||
WorldUpdated {
|
||||
world: String,
|
||||
},
|
||||
ServerJoined {
|
||||
host: String,
|
||||
port: u16,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
Edited,
|
||||
Removed,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Minecraft CLI argument logic
|
||||
use crate::launcher::parse_rules;
|
||||
use crate::profile::QuickPlayType;
|
||||
use crate::state::Credentials;
|
||||
use crate::{
|
||||
state::{MemorySettings, WindowSize},
|
||||
@@ -31,7 +32,12 @@ pub fn get_class_paths(
|
||||
.iter()
|
||||
.filter_map(|library| {
|
||||
if let Some(rules) = &library.rules {
|
||||
if !parse_rules(rules, java_arch, minecraft_updated) {
|
||||
if !parse_rules(
|
||||
rules,
|
||||
java_arch,
|
||||
&QuickPlayType::None,
|
||||
minecraft_updated,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -111,6 +117,7 @@ pub fn get_jvm_arguments(
|
||||
memory: MemorySettings,
|
||||
custom_args: Vec<String>,
|
||||
java_arch: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
log_config: Option<&LoggingConfiguration>,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
let mut parsed_arguments = Vec::new();
|
||||
@@ -130,6 +137,7 @@ pub fn get_jvm_arguments(
|
||||
)
|
||||
},
|
||||
java_arch,
|
||||
quick_play_type,
|
||||
)?;
|
||||
} else {
|
||||
parsed_arguments.push(format!(
|
||||
@@ -214,6 +222,7 @@ pub fn get_minecraft_arguments(
|
||||
version_type: &VersionType,
|
||||
resolution: WindowSize,
|
||||
java_arch: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
if let Some(arguments) = arguments {
|
||||
let mut parsed_arguments = Vec::new();
|
||||
@@ -233,9 +242,11 @@ pub fn get_minecraft_arguments(
|
||||
assets_directory,
|
||||
version_type,
|
||||
resolution,
|
||||
quick_play_type,
|
||||
)
|
||||
},
|
||||
java_arch,
|
||||
quick_play_type,
|
||||
)?;
|
||||
|
||||
Ok(parsed_arguments)
|
||||
@@ -253,6 +264,7 @@ pub fn get_minecraft_arguments(
|
||||
assets_directory,
|
||||
version_type,
|
||||
resolution,
|
||||
quick_play_type,
|
||||
)?);
|
||||
}
|
||||
Ok(parsed_arguments)
|
||||
@@ -273,6 +285,7 @@ fn parse_minecraft_argument(
|
||||
assets_directory: &Path,
|
||||
version_type: &VersionType,
|
||||
resolution: WindowSize,
|
||||
quick_play_type: &QuickPlayType,
|
||||
) -> crate::Result<String> {
|
||||
Ok(argument
|
||||
.replace("${accessToken}", access_token)
|
||||
@@ -326,7 +339,21 @@ fn parse_minecraft_argument(
|
||||
)
|
||||
.replace("${version_type}", version_type.as_str())
|
||||
.replace("${resolution_width}", &resolution.0.to_string())
|
||||
.replace("${resolution_height}", &resolution.1.to_string()))
|
||||
.replace("${resolution_height}", &resolution.1.to_string())
|
||||
.replace(
|
||||
"${quickPlaySingleplayer}",
|
||||
match quick_play_type {
|
||||
QuickPlayType::Singleplayer(world) => world,
|
||||
_ => "",
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
"${quickPlayMultiplayer}",
|
||||
match quick_play_type {
|
||||
QuickPlayType::Server(address) => address,
|
||||
_ => "",
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_arguments<F>(
|
||||
@@ -334,6 +361,7 @@ fn parse_arguments<F>(
|
||||
parsed_arguments: &mut Vec<String>,
|
||||
parse_function: F,
|
||||
java_arch: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
F: Fn(&str) -> crate::Result<String>,
|
||||
@@ -348,7 +376,7 @@ where
|
||||
}
|
||||
}
|
||||
Argument::Ruled { rules, value } => {
|
||||
if parse_rules(rules, java_arch, true) {
|
||||
if parse_rules(rules, java_arch, quick_play_type, true) {
|
||||
match value {
|
||||
ArgumentValue::Single(arg) => {
|
||||
parsed_arguments.push(parse_function(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Downloader for Minecraft data
|
||||
|
||||
use crate::launcher::parse_rules;
|
||||
use crate::profile::QuickPlayType;
|
||||
use crate::{
|
||||
event::{
|
||||
emit::{emit_loading, loading_try_for_each_concurrent},
|
||||
@@ -295,7 +296,7 @@ pub async fn download_libraries(
|
||||
stream::iter(libraries.iter())
|
||||
.map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move {
|
||||
if let Some(rules) = &library.rules {
|
||||
if !parse_rules(rules, java_arch, minecraft_updated) {
|
||||
if !parse_rules(rules, java_arch, &QuickPlayType::None, minecraft_updated) {
|
||||
tracing::trace!("Skipped library {}", &library.name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::event::emit::{emit_loading, init_or_edit_loading};
|
||||
use crate::event::{LoadingBarId, LoadingBarType};
|
||||
use crate::launcher::download::download_log_config;
|
||||
use crate::launcher::io::IOError;
|
||||
use crate::profile::QuickPlayType;
|
||||
use crate::state::{
|
||||
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
||||
};
|
||||
@@ -13,8 +14,10 @@ use chrono::Utc;
|
||||
use daedalus as d;
|
||||
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
|
||||
use daedalus::modded::LoaderVersion;
|
||||
use serde::Deserialize;
|
||||
use st::Profile;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Command;
|
||||
|
||||
mod args;
|
||||
@@ -28,11 +31,14 @@ pub mod download;
|
||||
pub fn parse_rules(
|
||||
rules: &[d::minecraft::Rule],
|
||||
java_version: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
minecraft_updated: bool,
|
||||
) -> bool {
|
||||
let mut x = rules
|
||||
.iter()
|
||||
.map(|x| parse_rule(x, java_version, minecraft_updated))
|
||||
.map(|x| {
|
||||
parse_rule(x, java_version, quick_play_type, minecraft_updated)
|
||||
})
|
||||
.collect::<Vec<Option<bool>>>();
|
||||
|
||||
if rules
|
||||
@@ -53,6 +59,7 @@ pub fn parse_rules(
|
||||
pub fn parse_rule(
|
||||
rule: &d::minecraft::Rule,
|
||||
java_version: &str,
|
||||
quick_play_type: &QuickPlayType,
|
||||
minecraft_updated: bool,
|
||||
) -> Option<bool> {
|
||||
use d::minecraft::{Rule, RuleAction};
|
||||
@@ -70,9 +77,14 @@ pub fn parse_rule(
|
||||
!features.is_demo_user.unwrap_or(true)
|
||||
|| features.has_custom_resolution.unwrap_or(false)
|
||||
|| !features.has_quick_plays_support.unwrap_or(true)
|
||||
|| !features.is_quick_play_multiplayer.unwrap_or(true)
|
||||
|| (features.is_quick_play_singleplayer.unwrap_or(false)
|
||||
&& matches!(
|
||||
quick_play_type,
|
||||
QuickPlayType::Singleplayer(_)
|
||||
))
|
||||
|| (features.is_quick_play_multiplayer.unwrap_or(false)
|
||||
&& matches!(quick_play_type, QuickPlayType::Server(..)))
|
||||
|| !features.is_quick_play_realms.unwrap_or(true)
|
||||
|| !features.is_quick_play_singleplayer.unwrap_or(true)
|
||||
}
|
||||
_ => return Some(true),
|
||||
};
|
||||
@@ -305,12 +317,11 @@ pub async fn install_minecraft(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client_path = state
|
||||
.directories
|
||||
.version_dir(&version_jar)
|
||||
.join(format!("{version_jar}.jar"));
|
||||
if let Some(processors) = &version_info.processors {
|
||||
let client_path = state
|
||||
.directories
|
||||
.version_dir(&version_jar)
|
||||
.join(format!("{version_jar}.jar"));
|
||||
|
||||
let libraries_dir = state.directories.libraries_dir();
|
||||
|
||||
if let Some(ref mut data) = version_info.data {
|
||||
@@ -403,8 +414,11 @@ pub async fn install_minecraft(
|
||||
}
|
||||
}
|
||||
|
||||
let protocol_version = read_protocol_version_from_jar(client_path).await?;
|
||||
|
||||
crate::api::profile::edit(&profile.path, |prof| {
|
||||
prof.install_stage = ProfileInstallStage::Installed;
|
||||
prof.protocol_version = protocol_version;
|
||||
|
||||
async { Ok(()) }
|
||||
})
|
||||
@@ -414,6 +428,34 @@ pub async fn install_minecraft(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_protocol_version_from_jar(
|
||||
path: PathBuf,
|
||||
) -> crate::Result<Option<i32>> {
|
||||
let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?;
|
||||
let Some(entry_index) = zip
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|x| matches!(x.filename().as_str(), Ok("version.json")))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct VersionData {
|
||||
protocol_version: Option<i32>,
|
||||
}
|
||||
|
||||
let mut data = vec![];
|
||||
zip.reader_with_entry(entry_index)
|
||||
.await?
|
||||
.read_to_end_checked(&mut data)
|
||||
.await?;
|
||||
let data: VersionData = serde_json::from_slice(&data)?;
|
||||
|
||||
Ok(data.protocol_version)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn launch_minecraft(
|
||||
@@ -426,6 +468,7 @@ pub async fn launch_minecraft(
|
||||
credentials: &Credentials,
|
||||
post_exit_hook: Option<String>,
|
||||
profile: &Profile,
|
||||
quick_play_type: &QuickPlayType,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
if profile.install_stage == ProfileInstallStage::PackInstalling
|
||||
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
|
||||
@@ -581,6 +624,7 @@ pub async fn launch_minecraft(
|
||||
*memory,
|
||||
Vec::from(java_args),
|
||||
&java_version.architecture,
|
||||
quick_play_type,
|
||||
version_info
|
||||
.logging
|
||||
.as_ref()
|
||||
@@ -603,6 +647,7 @@ pub async fn launch_minecraft(
|
||||
&version.type_,
|
||||
*resolution,
|
||||
&java_version.architecture,
|
||||
quick_play_type,
|
||||
)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -708,6 +753,12 @@ pub async fn launch_minecraft(
|
||||
// This also spawns the process and prepares the subsequent processes
|
||||
state
|
||||
.process_manager
|
||||
.insert_new_process(&profile.path, command, post_exit_hook)
|
||||
.insert_new_process(
|
||||
&profile.path,
|
||||
command,
|
||||
post_exit_hook,
|
||||
state.directories.profile_logs_dir(&profile.path),
|
||||
version_info.logging.is_some(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -37,9 +37,7 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
let mut found = false;
|
||||
for component in e.path.components() {
|
||||
if found {
|
||||
profile_path = Some(
|
||||
component.as_os_str().to_string_lossy(),
|
||||
);
|
||||
profile_path = Some(component.as_os_str());
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -51,26 +49,72 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
}
|
||||
|
||||
if let Some(profile_path) = profile_path {
|
||||
if e.path
|
||||
let profile_path_str =
|
||||
profile_path.to_string_lossy().to_string();
|
||||
let first_file_name = e
|
||||
.path
|
||||
.components()
|
||||
.any(|x| x.as_os_str() == "crash-reports")
|
||||
.skip_while(|x| x.as_os_str() != profile_path)
|
||||
.nth(1)
|
||||
.map(|x| x.as_os_str());
|
||||
if first_file_name
|
||||
.filter(|x| *x == "crash-reports")
|
||||
.is_some()
|
||||
&& e.path
|
||||
.extension()
|
||||
.map(|x| x == "txt")
|
||||
.unwrap_or(false)
|
||||
.filter(|x| *x == "txt")
|
||||
.is_some()
|
||||
{
|
||||
crash_task(profile_path.to_string());
|
||||
crash_task(profile_path_str);
|
||||
} else if !visited_profiles.contains(&profile_path)
|
||||
{
|
||||
let path = profile_path.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = emit_profile(
|
||||
&path,
|
||||
ProfilePayloadType::Synced,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
visited_profiles.push(profile_path);
|
||||
let event = if first_file_name
|
||||
.filter(|x| *x == "servers.dat")
|
||||
.is_some()
|
||||
{
|
||||
Some(ProfilePayloadType::ServersUpdated)
|
||||
} else if first_file_name
|
||||
.filter(|x| {
|
||||
*x == "saves"
|
||||
&& e.path
|
||||
.file_name()
|
||||
.filter(|x| *x == "level.dat")
|
||||
.is_some()
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
tracing::info!(
|
||||
"World updated: {}",
|
||||
e.path.display()
|
||||
);
|
||||
Some(ProfilePayloadType::WorldUpdated {
|
||||
world: e
|
||||
.path
|
||||
.parent()
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
})
|
||||
} else if first_file_name
|
||||
.filter(|x| *x == "saves")
|
||||
.is_none()
|
||||
{
|
||||
Some(ProfilePayloadType::Synced)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(event) = event {
|
||||
tokio::spawn(async move {
|
||||
let _ = emit_profile(
|
||||
&profile_path_str,
|
||||
event,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
visited_profiles.push(profile_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,13 +155,14 @@ pub(crate) async fn watch_profile(
|
||||
let profile_path = dirs.profiles_dir().join(profile_path);
|
||||
|
||||
if profile_path.exists() && profile_path.is_dir() {
|
||||
for folder in ProjectType::iterator()
|
||||
.map(|x| x.get_folder())
|
||||
.chain(["crash-reports"])
|
||||
{
|
||||
for folder in ProjectType::iterator().map(|x| x.get_folder()).chain([
|
||||
"crash-reports",
|
||||
"saves",
|
||||
"servers.dat",
|
||||
]) {
|
||||
let path = profile_path.join(folder);
|
||||
|
||||
if !path.exists() && !path.is_symlink() {
|
||||
if !path.exists() && !path.is_symlink() && !folder.contains(".") {
|
||||
if let Err(e) = crate::util::io::create_dir_all(&path).await {
|
||||
tracing::error!(
|
||||
"Failed to create directory for watcher {path:?}: {e}"
|
||||
|
||||
@@ -320,6 +320,7 @@ where
|
||||
name: profile.metadata.name,
|
||||
icon_path: profile.metadata.icon,
|
||||
game_version: profile.metadata.game_version,
|
||||
protocol_version: None,
|
||||
loader: profile.metadata.loader.into(),
|
||||
loader_version: profile
|
||||
.metadata
|
||||
|
||||
@@ -45,6 +45,8 @@ pub use self::mr_auth::*;
|
||||
|
||||
mod legacy_converter;
|
||||
|
||||
pub mod server_join_log;
|
||||
|
||||
// Global state
|
||||
// RwLock on state only has concurrent reads, except for config dir change which takes control of the State
|
||||
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
use crate::event::emit::emit_process;
|
||||
use crate::event::ProcessPayloadType;
|
||||
use crate::event::emit::{emit_process, emit_profile};
|
||||
use crate::event::{ProcessPayloadType, ProfilePayloadType};
|
||||
use crate::profile;
|
||||
use crate::util::io::IOError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use dashmap::DashMap;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::Reader;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitStatus;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use uuid::Uuid;
|
||||
|
||||
const LAUNCHER_LOG_PATH: &str = "launcher_log.txt";
|
||||
|
||||
pub struct ProcessManager {
|
||||
processes: DashMap<Uuid, Process>,
|
||||
}
|
||||
@@ -32,8 +40,16 @@ impl ProcessManager {
|
||||
profile_path: &str,
|
||||
mut mc_command: Command,
|
||||
post_exit_command: Option<String>,
|
||||
logs_folder: PathBuf,
|
||||
xml_logging: bool,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
let mc_proc = mc_command.spawn().map_err(IOError::from)?;
|
||||
mc_command.stdout(std::process::Stdio::piped());
|
||||
mc_command.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut mc_proc = mc_command.spawn().map_err(IOError::from)?;
|
||||
|
||||
let stdout = mc_proc.stdout.take();
|
||||
let stderr = mc_proc.stderr.take();
|
||||
|
||||
let process = Process {
|
||||
metadata: ProcessMetadata {
|
||||
@@ -46,6 +62,65 @@ impl ProcessManager {
|
||||
|
||||
let metadata = process.metadata.clone();
|
||||
|
||||
if !logs_folder.exists() {
|
||||
tokio::fs::create_dir_all(&logs_folder)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &logs_folder))?;
|
||||
}
|
||||
|
||||
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
|
||||
|
||||
{
|
||||
let mut log_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&log_path)
|
||||
.map_err(|e| IOError::with_path(e, &log_path))?;
|
||||
|
||||
// Initialize with timestamp header
|
||||
let now = chrono::Local::now();
|
||||
writeln!(
|
||||
log_file,
|
||||
"# Minecraft launcher log started at {}",
|
||||
now.format("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
.map_err(|e| IOError::with_path(e, &log_path))?;
|
||||
writeln!(log_file, "# Profile: {} \n", profile_path)
|
||||
.map_err(|e| IOError::with_path(e, &log_path))?;
|
||||
writeln!(log_file).map_err(|e| IOError::with_path(e, &log_path))?;
|
||||
}
|
||||
|
||||
if let Some(stdout) = stdout {
|
||||
let log_path_clone = log_path.clone();
|
||||
|
||||
let profile_path = metadata.profile_path.clone();
|
||||
tokio::spawn(async move {
|
||||
Process::process_output(
|
||||
&profile_path,
|
||||
stdout,
|
||||
log_path_clone,
|
||||
xml_logging,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
let log_path_clone = log_path.clone();
|
||||
|
||||
let profile_path = metadata.profile_path.clone();
|
||||
tokio::spawn(async move {
|
||||
Process::process_output(
|
||||
&profile_path,
|
||||
stderr,
|
||||
log_path_clone,
|
||||
xml_logging,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(Process::sequential_process_manager(
|
||||
profile_path.to_string(),
|
||||
post_exit_command,
|
||||
@@ -120,7 +195,381 @@ struct Process {
|
||||
child: Child,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Log4jEvent {
|
||||
timestamp: Option<String>,
|
||||
logger: Option<String>,
|
||||
level: Option<String>,
|
||||
thread: Option<String>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
impl Process {
|
||||
async fn process_output<R>(
|
||||
profile_path: &str,
|
||||
reader: R,
|
||||
log_path: impl AsRef<Path>,
|
||||
xml_logging: bool,
|
||||
) where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
{
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
|
||||
if xml_logging {
|
||||
let mut reader = Reader::from_reader(buf_reader);
|
||||
reader.config_mut().enable_all_checks(false);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut current_event = Log4jEvent::default();
|
||||
let mut in_event = false;
|
||||
let mut in_message = false;
|
||||
let mut in_throwable = false;
|
||||
let mut current_content = String::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event_into_async(&mut buf).await {
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error at position {}: {:?}",
|
||||
reader.buffer_position(),
|
||||
e
|
||||
);
|
||||
break;
|
||||
}
|
||||
// exits the loop when reaching end of file
|
||||
Ok(Event::Eof) => break,
|
||||
|
||||
Ok(Event::Start(e)) => {
|
||||
match e.name().as_ref() {
|
||||
b"log4j:Event" => {
|
||||
// Reset for new event
|
||||
current_event = Log4jEvent::default();
|
||||
in_event = true;
|
||||
|
||||
// Extract attributes
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(
|
||||
attr.key.into_inner(),
|
||||
)
|
||||
.to_string();
|
||||
let value =
|
||||
String::from_utf8_lossy(&attr.value)
|
||||
.to_string();
|
||||
|
||||
match key.as_str() {
|
||||
"logger" => {
|
||||
current_event.logger = Some(value)
|
||||
}
|
||||
"level" => {
|
||||
current_event.level = Some(value)
|
||||
}
|
||||
"thread" => {
|
||||
current_event.thread = Some(value)
|
||||
}
|
||||
"timestamp" => {
|
||||
current_event.timestamp =
|
||||
Some(value)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b"log4j:Message" => {
|
||||
in_message = true;
|
||||
current_content = String::new();
|
||||
}
|
||||
b"log4j:Throwable" => {
|
||||
in_throwable = true;
|
||||
current_content = String::new();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => {
|
||||
match e.name().as_ref() {
|
||||
b"log4j:Message" => {
|
||||
in_message = false;
|
||||
current_event.message =
|
||||
Some(current_content.clone());
|
||||
}
|
||||
b"log4j:Throwable" => {
|
||||
in_throwable = false;
|
||||
// Process and write the log entry
|
||||
let thread = current_event
|
||||
.thread
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let level = current_event
|
||||
.level
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let logger = current_event
|
||||
.logger
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
|
||||
if let Some(message) = ¤t_event.message {
|
||||
let formatted_time =
|
||||
Process::format_timestamp(
|
||||
current_event.timestamp.as_deref(),
|
||||
);
|
||||
let formatted_log = format!(
|
||||
"{} [{}] [{}{}]: {}\n",
|
||||
formatted_time,
|
||||
thread,
|
||||
if !logger.is_empty() {
|
||||
format!("{}/", logger)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
level,
|
||||
message.trim()
|
||||
);
|
||||
|
||||
// Write the log message
|
||||
if let Err(e) = Process::append_to_log_file(
|
||||
&log_path,
|
||||
&formatted_log,
|
||||
) {
|
||||
tracing::error!(
|
||||
"Failed to write to log file: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Write the throwable if present
|
||||
if !current_content.is_empty() {
|
||||
if let Err(e) =
|
||||
Process::append_to_log_file(
|
||||
&log_path,
|
||||
¤t_content,
|
||||
)
|
||||
{
|
||||
tracing::error!("Failed to write throwable to log file: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b"log4j:Event" => {
|
||||
in_event = false;
|
||||
// If no throwable was present, write the log entry at the end of the event
|
||||
if current_event.message.is_some()
|
||||
&& !in_throwable
|
||||
{
|
||||
let thread = current_event
|
||||
.thread
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let level = current_event
|
||||
.level
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let logger = current_event
|
||||
.logger
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let message = current_event
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
let formatted_time =
|
||||
Process::format_timestamp(
|
||||
current_event.timestamp.as_deref(),
|
||||
);
|
||||
let formatted_log = format!(
|
||||
"{} [{}] [{}{}]: {}\n",
|
||||
formatted_time,
|
||||
thread,
|
||||
if !logger.is_empty() {
|
||||
format!("{}/", logger)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
level,
|
||||
message
|
||||
);
|
||||
|
||||
// Write the log message
|
||||
if let Err(e) = Process::append_to_log_file(
|
||||
&log_path,
|
||||
&formatted_log,
|
||||
) {
|
||||
tracing::error!(
|
||||
"Failed to write to log file: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(timestamp) =
|
||||
current_event.timestamp.as_deref()
|
||||
{
|
||||
if let Err(e) = Self::maybe_handle_server_join_logging(
|
||||
profile_path,
|
||||
timestamp,
|
||||
message
|
||||
).await {
|
||||
tracing::error!("Failed to handle server join logging: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(mut e)) => {
|
||||
if in_message || in_throwable {
|
||||
if let Ok(text) = e.unescape() {
|
||||
current_content.push_str(&text);
|
||||
}
|
||||
} else if !in_event
|
||||
&& !e.inplace_trim_end()
|
||||
&& !e.inplace_trim_start()
|
||||
{
|
||||
if let Ok(text) = e.unescape() {
|
||||
if let Err(e) = Process::append_to_log_file(
|
||||
&log_path,
|
||||
&format!("{text}\n"),
|
||||
) {
|
||||
tracing::error!(
|
||||
"Failed to write to log file: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::CData(e)) => {
|
||||
if in_message || in_throwable {
|
||||
if let Ok(text) = e
|
||||
.escape()
|
||||
.map_err(|x| x.into())
|
||||
.and_then(|x| x.unescape())
|
||||
{
|
||||
current_content.push_str(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
} else {
|
||||
let mut line = String::new();
|
||||
|
||||
while let Ok(bytes_read) = buf_reader.read_line(&mut line).await {
|
||||
if bytes_read == 0 {
|
||||
break; // End of stream
|
||||
}
|
||||
|
||||
if !line.is_empty() {
|
||||
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
|
||||
tracing::warn!("Failed to write to log file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(timestamp: Option<&str>) -> String {
|
||||
if let Some(timestamp_str) = timestamp {
|
||||
if let Ok(timestamp_val) = timestamp_str.parse::<i64>() {
|
||||
let datetime_utc = if timestamp_val > i32::MAX as i64 {
|
||||
let secs = timestamp_val / 1000;
|
||||
let nsecs = ((timestamp_val % 1000) * 1_000_000) as u32;
|
||||
|
||||
chrono::DateTime::<Utc>::from_timestamp(secs, nsecs)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
chrono::DateTime::<Utc>::from_timestamp(timestamp_val, 0)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let datetime_local = datetime_utc.with_timezone(&chrono::Local);
|
||||
format!("[{}]", datetime_local.format("%H:%M:%S"))
|
||||
} else {
|
||||
"[??:??:??]".to_string()
|
||||
}
|
||||
} else {
|
||||
"[??:??:??]".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn append_to_log_file(
|
||||
path: impl AsRef<Path>,
|
||||
line: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let mut file =
|
||||
OpenOptions::new().append(true).create(true).open(path)?;
|
||||
|
||||
file.write_all(line.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn maybe_handle_server_join_logging(
|
||||
profile_path: &str,
|
||||
timestamp: &str,
|
||||
message: &str,
|
||||
) -> crate::Result<()> {
|
||||
let Some(host_port_string) = message.strip_prefix("Connecting to ")
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(port) = port_string.parse::<u16>().ok() else {
|
||||
return Ok(());
|
||||
};
|
||||
let timestamp = timestamp
|
||||
.parse::<i64>()
|
||||
.map(|x| x / 1000)
|
||||
.map_err(|x| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Failed to parse timestamp: {x}"
|
||||
))
|
||||
})
|
||||
.and_then(|x| {
|
||||
Utc.timestamp_opt(x, 0).single().ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(
|
||||
"Failed to convert timestamp to DateTime".to_string(),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
let state = crate::State::get().await?;
|
||||
crate::state::server_join_log::JoinLogEntry {
|
||||
profile_path: profile_path.to_owned(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
join_time: timestamp,
|
||||
}
|
||||
.upsert(&state.pool)
|
||||
.await?;
|
||||
{
|
||||
let profile_path = profile_path.to_owned();
|
||||
let host = host.to_owned();
|
||||
tokio::spawn(async move {
|
||||
let _ = emit_profile(
|
||||
&profile_path,
|
||||
ProfilePayloadType::ServerJoined {
|
||||
host,
|
||||
port,
|
||||
timestamp,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Spawns a new child process and inserts it into the hashmap
|
||||
// Also, as the process ends, it spawns the follow-up process if it exists
|
||||
// By convention, ExitStatus is last command's exit status, and we exit on the first non-zero exit status
|
||||
@@ -204,6 +653,24 @@ impl Process {
|
||||
}
|
||||
});
|
||||
|
||||
let logs_folder = state.directories.profile_logs_dir(&profile_path);
|
||||
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
|
||||
|
||||
if log_path.exists() {
|
||||
if let Err(e) = Process::append_to_log_file(
|
||||
&log_path,
|
||||
&format!(
|
||||
"\n# Process exited with status: {}\n",
|
||||
mc_exit_status
|
||||
),
|
||||
) {
|
||||
tracing::warn!(
|
||||
"Failed to write exit status to log file: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = state.discord_rpc.clear_to_default(true).await;
|
||||
|
||||
let _ = state.friends_socket.update_status(None).await;
|
||||
|
||||
@@ -23,6 +23,7 @@ pub struct Profile {
|
||||
pub icon_path: Option<String>,
|
||||
|
||||
pub game_version: String,
|
||||
pub protocol_version: Option<i32>,
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
|
||||
@@ -261,6 +262,7 @@ struct ProfileQueryResult {
|
||||
override_hook_pre_launch: Option<String>,
|
||||
override_hook_wrapper: Option<String>,
|
||||
override_hook_post_exit: Option<String>,
|
||||
protocol_version: Option<i64>,
|
||||
}
|
||||
|
||||
impl TryFrom<ProfileQueryResult> for Profile {
|
||||
@@ -273,6 +275,7 @@ impl TryFrom<ProfileQueryResult> for Profile {
|
||||
name: x.name,
|
||||
icon_path: x.icon_path,
|
||||
game_version: x.game_version,
|
||||
protocol_version: x.protocol_version.map(|x| x as i32),
|
||||
loader: ModLoader::from_string(&x.mod_loader),
|
||||
loader_version: x.mod_loader_version,
|
||||
groups: serde_json::from_value(x.groups).unwrap_or_default(),
|
||||
@@ -337,7 +340,7 @@ macro_rules! select_profiles_with_predicate {
|
||||
r#"
|
||||
SELECT
|
||||
path, install_stage, name, icon_path,
|
||||
game_version, mod_loader, mod_loader_version,
|
||||
game_version, protocol_version, mod_loader, mod_loader_version,
|
||||
json(groups) as "groups!: serde_json::Value",
|
||||
linked_project_id, linked_version_id, locked,
|
||||
created, modified, last_played,
|
||||
@@ -435,7 +438,8 @@ impl Profile {
|
||||
submitted_time_played, recent_time_played,
|
||||
override_java_path, override_extra_launch_args, override_custom_env_vars,
|
||||
override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,
|
||||
override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit
|
||||
override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,
|
||||
protocol_version
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4,
|
||||
@@ -446,7 +450,8 @@ impl Profile {
|
||||
$15, $16,
|
||||
$17, jsonb($18), jsonb($19),
|
||||
$20, $21, $22, $23,
|
||||
$24, $25, $26
|
||||
$24, $25, $26,
|
||||
$27
|
||||
)
|
||||
ON CONFLICT (path) DO UPDATE SET
|
||||
install_stage = $2,
|
||||
@@ -480,7 +485,9 @@ impl Profile {
|
||||
|
||||
override_hook_pre_launch = $24,
|
||||
override_hook_wrapper = $25,
|
||||
override_hook_post_exit = $26
|
||||
override_hook_post_exit = $26,
|
||||
|
||||
protocol_version = $27
|
||||
",
|
||||
self.path,
|
||||
install_stage,
|
||||
@@ -508,6 +515,7 @@ impl Profile {
|
||||
self.hooks.pre_launch,
|
||||
self.hooks.wrapper,
|
||||
self.hooks.post_exit,
|
||||
self.protocol_version,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
64
packages/app-lib/src/state/server_join_log.rs
Normal file
64
packages/app-lib/src/state/server_join_log.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
pub struct JoinLogEntry {
|
||||
pub profile_path: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub join_time: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl JoinLogEntry {
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let join_time = self.join_time.timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO join_log (profile_path, host, port, join_time)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (profile_path, host, port) DO UPDATE SET
|
||||
join_time = $4
|
||||
",
|
||||
self.profile_path,
|
||||
self.host,
|
||||
self.port,
|
||||
join_time
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_joins(
|
||||
instance: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<HashMap<(String, u16), DateTime<Utc>>> {
|
||||
let joins = sqlx::query!(
|
||||
"
|
||||
SELECT profile_path, host, port, join_time
|
||||
FROM join_log
|
||||
WHERE profile_path = $1
|
||||
",
|
||||
instance
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(joins
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
(
|
||||
(x.host, x.port as u16),
|
||||
Utc.timestamp_opt(x.join_time, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -255,3 +255,29 @@ pub async fn remove_file(
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// remove dir
|
||||
pub async fn remove_dir(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::remove_dir(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// metadata
|
||||
pub async fn metadata(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<std::fs::Metadata, IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::metadata(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod fetch;
|
||||
pub mod io;
|
||||
pub mod jre;
|
||||
pub mod platform;
|
||||
pub mod server_ping;
|
||||
|
||||
/// Wrap a builder which uses a mut reference into one which outputs an owned value
|
||||
macro_rules! wrap_ref_builder {
|
||||
|
||||
223
packages/app-lib/src/util/server_ping.rs
Normal file
223
packages/app-lib/src/util/server_ping.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use crate::error::Result;
|
||||
use crate::ErrorKind;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use std::time::Duration;
|
||||
use tokio::net::ToSocketAddrs;
|
||||
use tokio::select;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerStatus {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<Box<RawValue>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub players: Option<ServerPlayers>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<ServerVersion>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub favicon: Option<Url>,
|
||||
#[serde(default)]
|
||||
pub enforces_secure_chat: bool,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ping: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ServerPlayers {
|
||||
pub max: i32,
|
||||
pub online: i32,
|
||||
#[serde(default)]
|
||||
pub sample: Vec<ServerGameProfile>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ServerGameProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ServerVersion {
|
||||
pub name: String,
|
||||
pub protocol: i32,
|
||||
}
|
||||
|
||||
pub async fn get_server_status(
|
||||
address: &impl ToSocketAddrs,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<i32>,
|
||||
) -> Result<ServerStatus> {
|
||||
select! {
|
||||
res = modern::status(address, original_address, protocol_version) => res,
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => Err(ErrorKind::OtherError(
|
||||
format!("Ping of {}:{} timed out", original_address.0, original_address.1)
|
||||
).into())
|
||||
}
|
||||
}
|
||||
|
||||
mod modern {
|
||||
use super::ServerStatus;
|
||||
use crate::ErrorKind;
|
||||
use chrono::Utc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||
|
||||
pub async fn status(
|
||||
address: &impl ToSocketAddrs,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<i32>,
|
||||
) -> crate::Result<ServerStatus> {
|
||||
let mut stream = TcpStream::connect(address).await?;
|
||||
handshake(&mut stream, original_address, protocol_version).await?;
|
||||
let mut result = status_body(&mut stream).await?;
|
||||
result.ping = ping(&mut stream).await.ok();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn handshake(
|
||||
stream: &mut TcpStream,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<i32>,
|
||||
) -> crate::Result<()> {
|
||||
let (host, port) = original_address;
|
||||
let protocol_version = protocol_version.unwrap_or(-1);
|
||||
|
||||
const PACKET_ID: i32 = 0;
|
||||
const NEXT_STATE: i32 = 1;
|
||||
|
||||
let packet_size = varint::get_byte_size(PACKET_ID)
|
||||
+ varint::get_byte_size(protocol_version)
|
||||
+ varint::get_byte_size(host.len() as i32)
|
||||
+ host.len()
|
||||
+ size_of::<u16>()
|
||||
+ varint::get_byte_size(NEXT_STATE);
|
||||
|
||||
let mut packet_buffer = Vec::with_capacity(
|
||||
varint::get_byte_size(packet_size as i32) + packet_size,
|
||||
);
|
||||
|
||||
varint::write(&mut packet_buffer, packet_size as i32);
|
||||
varint::write(&mut packet_buffer, PACKET_ID);
|
||||
varint::write(&mut packet_buffer, protocol_version);
|
||||
varint::write(&mut packet_buffer, host.len() as i32);
|
||||
packet_buffer.extend_from_slice(host.as_bytes());
|
||||
packet_buffer.extend_from_slice(&port.to_be_bytes());
|
||||
varint::write(&mut packet_buffer, NEXT_STATE);
|
||||
|
||||
stream.write_all(&packet_buffer).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status_body(
|
||||
stream: &mut TcpStream,
|
||||
) -> crate::Result<ServerStatus> {
|
||||
stream.write_all(&[0x01, 0x00]).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let packet_length = varint::read(stream).await?;
|
||||
if packet_length < 0 {
|
||||
return Err(ErrorKind::InputError(
|
||||
"Invalid status response packet length".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut packet_stream = stream.take(packet_length as u64);
|
||||
let packet_id = varint::read(&mut packet_stream).await?;
|
||||
if packet_id != 0x00 {
|
||||
return Err(ErrorKind::InputError(
|
||||
"Unexpected status response".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let response_length = varint::read(&mut packet_stream).await?;
|
||||
let mut json_response = vec![0_u8; response_length as usize];
|
||||
packet_stream.read_exact(&mut json_response).await?;
|
||||
|
||||
if packet_stream.limit() > 0 {
|
||||
tokio::io::copy(&mut packet_stream, &mut tokio::io::sink()).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::from_slice(&json_response)?)
|
||||
}
|
||||
|
||||
async fn ping(stream: &mut TcpStream) -> crate::Result<i64> {
|
||||
let start_time = Utc::now();
|
||||
let ping_magic = start_time.timestamp_millis();
|
||||
|
||||
stream.write_all(&[0x09, 0x01]).await?;
|
||||
stream.write_i64(ping_magic).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let mut response_prefix = [0_u8; 2];
|
||||
stream.read_exact(&mut response_prefix).await?;
|
||||
let response_magic = stream.read_i64().await?;
|
||||
if response_prefix != [0x09, 0x01] || response_magic != ping_magic {
|
||||
return Err(ErrorKind::InputError(
|
||||
"Unexpected ping response".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let response_time = Utc::now();
|
||||
Ok((response_time - start_time).num_milliseconds())
|
||||
}
|
||||
|
||||
mod varint {
|
||||
use std::io;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
|
||||
const MAX_VARINT_SIZE: usize = 5;
|
||||
const DATA_BITS_MASK: u32 = 0x7f;
|
||||
const CONT_BIT_MASK_U8: u8 = 0x80;
|
||||
const CONT_BIT_MASK_U32: u32 = CONT_BIT_MASK_U8 as u32;
|
||||
const DATA_BITS_PER_BYTE: usize = 7;
|
||||
|
||||
pub fn get_byte_size(x: i32) -> usize {
|
||||
let x = x as u32;
|
||||
for size in 1..MAX_VARINT_SIZE {
|
||||
if (x & (u32::MAX << (size * DATA_BITS_PER_BYTE))) == 0 {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
MAX_VARINT_SIZE
|
||||
}
|
||||
|
||||
pub fn write(out: &mut Vec<u8>, value: i32) {
|
||||
let mut value = value as u32;
|
||||
while value >= CONT_BIT_MASK_U32 {
|
||||
out.push(((value & DATA_BITS_MASK) | CONT_BIT_MASK_U32) as u8);
|
||||
value >>= DATA_BITS_PER_BYTE;
|
||||
}
|
||||
out.push(value as u8);
|
||||
}
|
||||
|
||||
pub async fn read<R: AsyncRead + Unpin>(
|
||||
reader: &mut R,
|
||||
) -> io::Result<i32> {
|
||||
let mut result = 0;
|
||||
let mut shift = 0;
|
||||
|
||||
loop {
|
||||
let b = reader.read_u8().await?;
|
||||
result |=
|
||||
(b as u32 & DATA_BITS_MASK) << (shift * DATA_BITS_PER_BYTE);
|
||||
shift += 1;
|
||||
if shift > MAX_VARINT_SIZE {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"VarInt too big",
|
||||
));
|
||||
}
|
||||
if b & CONT_BIT_MASK_U8 == 0 {
|
||||
return Ok(result as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user